Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions ATS_ACCEPTABLE_USE_POLICY.md

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ ATS setup asks for a local memory folder, a memory size, a strategy folder and
data provider/symbol settings. Local resources belong to the verified account
and agent; rotating a token preserves that identity. Switching accounts while
chat is open closes its local resources and requires reopening the conversation.
Before the first setup for a policy version, the terminal presents the
[ATS Autonomous Trading Acceptable Use, Risk and Data Policy](ATS_ACCEPTABLE_USE_POLICY.md):
choose `1` to accept or `2` to reject. Rejection exits before creating an agent
or configuring storage, strategies, datafeeds, browsers, plugins or MCP. The
local, pseudonymous consent receipt records the policy digest but grants no
broker or trading authority.
The packaged adapters verify storage and report native Nano compiler results.
Inside ATS chat, type /ats status, /ats strategies or /ats data to inspect the
workspace. Shift-Tab cycles the
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"docs/model-catalogue/index.html",
"LICENSE",
"NOTICE.md",
"ATS_ACCEPTABLE_USE_POLICY.md",
"packages/ats-skills",
"packages/ats-skills-source.json"
],
Expand Down
2 changes: 1 addition & 1 deletion scripts/release-candidate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,7 @@ export function runReleaseCandidate(repoRoot: string, options: CandidateOptions
});

// 8. The handoff demo, driven against the INSTALLED package. The demo
// harness is not shipped (the allowlist is dist/src plus four docs), so
// harness is not shipped (the allowlist is dist/src plus reviewed public docs), so
// the harness is copied beside the installed package and resolves the CLI
// and its imports from the package's own dist/src — the tarball's code,
// not the checkout's.
Expand Down
2 changes: 1 addition & 1 deletion scripts/release-truth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,7 @@ export function deterministicRepositoryEvidence(root: string = process.cwd()): R
return evidence;
}

const PACKED_PUBLIC_DOCS = ["README.md", "COMMANDS.md", "NOTICE.md", "docs/generated/commands.md", "docs/generated/model-catalogue.md"] as const;
const PACKED_PUBLIC_DOCS = ["README.md", "COMMANDS.md", "NOTICE.md", "ATS_ACCEPTABLE_USE_POLICY.md", "docs/generated/commands.md", "docs/generated/model-catalogue.md"] as const;

/**
* Registry state is owner-controlled and flips the moment a release is published, so packed
Expand Down
3 changes: 2 additions & 1 deletion scripts/verify-production.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export interface PackReport {
}

const REQUIRED_ROOT_FILES = new Set([
"ATS_ACCEPTABLE_USE_POLICY.md",
"COMMANDS.md",
"LICENSE",
"NOTICE.md",
Expand Down Expand Up @@ -319,7 +320,7 @@ export function validateRuntimeGraph(root: string): string[] {
* What `npm pack` would actually ship from `root`, as a dry run.
*
* Exported because the source checkout's `dist/` is NOT the package — the files
* allowlist is `dist/src` plus four docs — so any gate reasoning about what a
* allowlist is `dist/src` plus the reviewed public documents — so any gate reasoning about what a
* user receives has to ask npm rather than read the build directory.
*/
export function createPackReport(root: string): PackReport {
Expand Down
1 change: 1 addition & 0 deletions scripts/vps-ci-release-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ for (const forbiddenHook of forbiddenHooks) {
const publishedFiles = new Set(packageManifest.files ?? []);
for (const path of [
"dist/src",
"ATS_ACCEPTABLE_USE_POLICY.md",
"README.md",
"COMMANDS.md",
"docs/generated/commands.md",
Expand Down
16 changes: 15 additions & 1 deletion src/commands/ats_agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { managedAccountOperation, managedAgentStorageDirectory, managedBrowserOw
import { theme } from "../ui/theme.js";
import { sanitizeTerm } from "../ui/text.js";
import { AgentBrowserSession, type AgentBrowserObserver, type AgentBrowserPackage } from "../core/agent_browser_session.js";
import { requireAtsPolicyAcceptance } from "./ats_policy.js";

export const ATS_PROFILE_MARKER = "aether.ats.profile/1";

Expand Down Expand Up @@ -81,6 +82,8 @@ export interface AtsHookDeps {
output?: (text: string) => void;
env?: NodeJS.ProcessEnv;
openViewer?: (url: string) => Promise<{ launched: boolean }>;
/** Dependency seam for policy-flow tests. Production callers must not override this. */
acceptPolicy?: (account: ManagedAccountScope, signal?: AbortSignal) => Promise<boolean>;
}

async function loadPackage(): Promise<AtsPackage> {
Expand Down Expand Up @@ -433,6 +436,14 @@ export function createAtsHooks(deps: AtsHookDeps = {}): ManagedAgentHooks {
return true;
},
createATS: async (ctx, name, signal) => {
const initialAccount = await accountFor(ctx, signal);
const accepted = await (deps.acceptPolicy
? deps.acceptPolicy(initialAccount, signal)
: requireAtsPolicyAcceptance({ root, account: initialAccount, signal, out: process.stdout }));
if (!accepted) {
output("ATS policy rejected. No agent, storage, strategy, datafeed, browser, plugin, or MCP setup was created.\n");
return 2;
}
const options = await (deps.setup ?? askSetup)(signal);
signal?.throwIfAborted();
const pack = await load();
Expand All @@ -443,7 +454,10 @@ export function createAtsHooks(deps: AtsHookDeps = {}): ManagedAgentHooks {
await draft.complete();
try {
const account = await accountFor(ctx, signal);
if (account.accountSubject !== draft.accountScope.accountSubject || account.cloudOrigin !== draft.accountScope.cloudOrigin) throw new Error("The account changed after agent creation. Resume from the original account.");
if (account.accountSubject !== initialAccount.accountSubject || account.cloudOrigin !== initialAccount.cloudOrigin
|| account.accountSubject !== draft.accountScope.accountSubject || account.cloudOrigin !== draft.accountScope.cloudOrigin) {
throw new Error("The account changed after policy acceptance or agent creation. Resume from the original account.");
}
await refuseLegacyBinding(account, agent.agent_id, root);
await initialize(account, agent, options, pack, output, signal);
}
Expand Down
159 changes: 159 additions & 0 deletions src/commands/ats_policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { createHash, randomUUID } from "node:crypto";
import { createInterface } from "node:readline/promises";
import type { Writable } from "node:stream";
import { lstat, mkdir, open, readFile, rename, unlink } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import type { ManagedAccountScope } from "../core/managed_agent_local.js";
import { managedChatInput } from "../ui/managed_chat_input.js";
import { leaseTerminalInput } from "../ui/input_lease.js";

export const ATS_POLICY_VERSION = "1.0.0";
export const ATS_POLICY_EFFECTIVE_DATE = "2026-09-18";
export const ATS_POLICY_SHA256 = "30a6e043617a9089f0306a8ee4b15054d33fac296bb8c284a9ec044c98a56154";
export const ATS_POLICY_URL = "https://github.com/AetherAI3/Aether-Agent/blob/main/ATS_ACCEPTABLE_USE_POLICY.md";

interface AtsPolicyReceipt {
schema_version: "aether.ats.policy-consent/1";
policy_version: string;
policy_effective_date: string;
policy_sha256: string;
accepted_at: string;
account_scope_sha256: string;
decision: "accepted";
grants_trading_authority: false;
}

export interface AtsPolicyAcceptanceOptions {
root: string;
account: ManagedAccountScope;
signal?: AbortSignal;
input?: NodeJS.ReadableStream & { isTTY?: boolean };
out?: Writable;
}

function accountDigest(account: ManagedAccountScope): string {
return createHash("sha256").update(account.cloudOrigin).update("\0").update(account.accountSubject).digest("hex");
}

export function atsPolicyReceiptPath(root: string, account: ManagedAccountScope): string {
return join(root, "policy-consents", `${accountDigest(account)}.json`);
}

async function refuseLinks(path: string): Promise<void> {
let current = resolve(path);
for (;;) {
try { if ((await lstat(current)).isSymbolicLink()) throw new Error("ATS policy consent storage cannot follow a symbolic link."); }
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
const parent = dirname(current);
if (parent === current) return;
current = parent;
}
}

function validReceipt(value: unknown, account: ManagedAccountScope): value is AtsPolicyReceipt {
if (!value || typeof value !== "object") return false;
const receipt = value as Partial<AtsPolicyReceipt>;
return receipt.schema_version === "aether.ats.policy-consent/1"
&& receipt.policy_version === ATS_POLICY_VERSION
&& receipt.policy_effective_date === ATS_POLICY_EFFECTIVE_DATE
&& receipt.policy_sha256 === ATS_POLICY_SHA256
&& receipt.account_scope_sha256 === accountDigest(account)
&& receipt.decision === "accepted"
&& receipt.grants_trading_authority === false
&& typeof receipt.accepted_at === "string"
&& Number.isFinite(Date.parse(receipt.accepted_at));
}

async function readReceipt(path: string, account: ManagedAccountScope): Promise<AtsPolicyReceipt | null> {
await refuseLinks(path);
try {
const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
return validReceipt(parsed, account) ? parsed : null;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
if (error instanceof SyntaxError) return null;
throw error;
}
}

async function writeReceipt(path: string, account: ManagedAccountScope): Promise<void> {
await refuseLinks(path);
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
const receipt: AtsPolicyReceipt = {
schema_version: "aether.ats.policy-consent/1",
policy_version: ATS_POLICY_VERSION,
policy_effective_date: ATS_POLICY_EFFECTIVE_DATE,
policy_sha256: ATS_POLICY_SHA256,
accepted_at: new Date().toISOString(),
account_scope_sha256: accountDigest(account),
decision: "accepted",
grants_trading_authority: false,
};
const temporary = `${path}.${randomUUID()}.tmp`;
try {
const file = await open(temporary, "wx", 0o600);
try { await file.writeFile(JSON.stringify(receipt, null, 2) + "\n"); await file.sync(); }
finally { await file.close(); }
await rename(temporary, path);
} finally { await unlink(temporary).catch(() => {}); }
}

const NOTICE = `
Aether ATS Autonomous Trading Policy v${ATS_POLICY_VERSION} (${ATS_POLICY_EFFECTIVE_DATE})

ATS can use autonomous agents, bundled Nano strategies, browser tools, plugins,
datafeeds and MCP connections. If you separately enable execution, orders run
through accounts, credentials and infrastructure that you select and control.

Material terms:
• Trading can cause rapid, substantial or total loss, including losses beyond deposits.
• Aether provides software, not individualized investment, legal or tax advice.
• Strategies, backtests, data and AI outputs can be wrong, stale or unsuitable.
• You control execution authority and are responsible for supervision, limits,
reconciliation, broker/venue terms, regulatory compliance, taxes and losses.
• Plugins, browser pages, datafeeds and MCP servers are independent, untrusted
third parties and may process data under their own terms.
• Do not put broker secrets, private keys or passwords in prompts, chats or files.
• Acceptance does not connect a broker or grant this agent trading authority.
• Warranty, liability and indemnity terms apply, subject to non-waivable law.

Full policy (bundled as ATS_ACCEPTABLE_USE_POLICY.md):
${ATS_POLICY_URL}
SHA-256: ${ATS_POLICY_SHA256}

1 — Accept and continue
2 — Reject and stop setup
`;

/** Require versioned clickwrap before any ATS draft, storage, strategy or connector setup. */
export async function requireAtsPolicyAcceptance(options: AtsPolicyAcceptanceOptions): Promise<boolean> {
const input = options.input ?? process.stdin;
const out = options.out ?? process.stdout;
const path = atsPolicyReceiptPath(options.root, options.account);
if (await readReceipt(path, options.account)) return true;
if (!input.isTTY) throw new Error("ATS policy acceptance requires an interactive terminal. Run `aether agent create ATS <name>` and choose 1 or 2.");
options.signal?.throwIfAborted();
const release = leaseTerminalInput(input);
const controller = new AbortController();
const cancel = (): void => controller.abort();
const active = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
const owned = managedChatInput(input);
const reader = createInterface({ input: owned.input, output: out, terminal: true });
reader.on("SIGINT", cancel);
reader.on("close", cancel);
try {
out.write(NOTICE);
for (;;) {
const answer = (await reader.question("Choice [2]: ", { signal: active })).trim() || "2";
if (answer === "2") return false;
if (answer === "1") { await writeReceipt(path, options.account); return true; }
out.write("Enter 1 to accept or 2 to reject.\n");
}
} finally {
reader.removeListener("SIGINT", cancel);
reader.removeListener("close", cancel);
reader.close();
owned.dispose();
release();
}
}
Loading
Loading