diff --git a/ATS_ACCEPTABLE_USE_POLICY.md b/ATS_ACCEPTABLE_USE_POLICY.md new file mode 100644 index 0000000..7ec68dc --- /dev/null +++ b/ATS_ACCEPTABLE_USE_POLICY.md @@ -0,0 +1,158 @@ +# Aether ATS Autonomous Trading Acceptable Use, Risk and Data Policy + +**Version:** 1.0.0 +**Effective date:** September 18, 2026 +**Provider:** Aether AI LLC ("Aether," "we," "us," or "our") + +This Policy is a binding condition of installing, enabling, or using the Aether Agent trading profile, Aether Trading System features, the `aether-ats-skills` package, bundled Nano strategy sources, agent browser features, trading-related plugins, datafeeds, or Model Context Protocol (MCP) connections (together, "ATS"). It supplements the Aether terms and privacy notice presented with the applicable service. If those terms conflict with this Policy, the more protective requirement applies to ATS unless applicable law requires otherwise. + +Read this Policy before continuing. Selecting **1 — Accept** creates an electronic record that you reviewed and agreed to this version. Selecting **2 — Reject** stops ATS setup. Acceptance does not itself authorize a trade, connect a broker, expose a credential, or grant an agent live-capital authority. + +## 1. What ATS is + +ATS is software that can help a user research markets, prepare or compile strategies, inspect user-selected data, operate user-enabled browser or tool connections, and—when the user separately configures and authorizes local execution—submit or manage trading instructions from the user's own environment. + +ATS may include autonomous or semi-autonomous agents, probabilistic AI models, open-source components, example strategies, Nano source files, browser automation, local memory, third-party plugins, broker or exchange interfaces, market-data services, and MCP servers. Outputs can be inaccurate, incomplete, stale, duplicated, delayed, or unsuitable for the user's account, goals, or legal obligations. + +Unless Aether expressly agrees otherwise in a separate signed writing, Aether provides software—not individualized financial, investment, legal, accounting, or tax advice. Aether is not your broker-dealer, investment adviser, commodity trading adviser, futures commission merchant, exchange, clearing firm, bank, custodian, fiduciary, or account manager. ATS availability, model output, a strategy file, a backtest, an alert, or an agent message is not a recommendation, solicitation, guarantee, or promise of performance. + +## 2. User-environment execution and control + +Execution occurs through infrastructure, accounts, credentials, software, browsers, plugins, MCP servers, brokers, exchanges, data vendors, or devices selected and controlled by the user. Aether does not take custody of the user's cash, securities, commodities, digital assets, brokerage account, or broker credentials merely because ATS software is installed or used. + +The user decides whether to connect an execution venue and whether to permit paper, simulated, evaluation, prop-firm, or live-capital activity. The user is solely responsible for: + +- selecting and lawfully maintaining accounts, venues, data rights, connectors, permissions, and credentials; +- understanding every permission granted to an agent, plugin, browser session, MCP server, or local process; +- reviewing strategy logic, instrument support, order types, sizing, leverage, fees, slippage, liquidity, session rules, and failure behavior; +- setting and testing position, order, notional, loss, drawdown, frequency, concurrency, and time limits; +- monitoring activity and maintaining a tested human stop, credential revocation path, and broker-side kill switch; +- reconciling orders, fills, rejections, cancellations, positions, cash, and uncertain outcomes before retrying an action; and +- all gains, losses, taxes, fees, margin calls, penalties, account restrictions, and contractual consequences produced through the user's environment. + +Creating an ATS agent, opening chat, installing Nano sources, compiling a strategy, assigning UVT, selecting a permission preference, connecting data, or opening a browser does not by itself create execution authority. Execution authority must be granted separately through the user's environment and must remain limited to the exact account, connector, scope, duration, and risk controls chosen by the user. A lost, stale, expired, disconnected, mismatched, or unknown authorization state must be treated as no authority. + +Paper or simulated trading is strongly recommended before any evaluation or live-capital use. Simulation and backtesting have inherent limitations and do not predict live performance. + +## 3. Autonomous-agent risks + +The user understands that an agent may act faster, more often, and at a larger cumulative scale than a human; misread a page or tool response; reason from stale or corrupted data; hallucinate facts; repeat an action after a timeout; fail to cancel or exit; choose an unintended symbol, side, quantity, price, account, or order type; interact with malicious content; or behave unexpectedly after a model, prompt, strategy, dependency, broker UI, API, or market change. + +The user must not treat natural-language confirmation as proof of an order or account state. Broker or venue records control. A timeout or missing response is an uncertain outcome, not proof that nothing happened. The user must reconcile before replay. No autonomous process should be left unmonitored where doing so would violate law, venue rules, the user's contractual obligations, or reasonable risk controls. + +## 4. Nano strategies, skills, examples, and performance information + +Bundled Nano strategies, Aether Agent skills, templates, prompts, indicators, examples, tests, and documentation are software and educational materials. They are not tailored to the user and are not endorsed as profitable or suitable. Inclusion in ATS does not mean that Aether has independently validated every market assumption, data dependency, parameter, fill model, or third-party claim. + +Backtests, paper results, examples, benchmarks, hypothetical results, and historical results have limitations. They may omit fees, spread, slippage, latency, borrow availability, market impact, corporate actions, taxes, liquidity constraints, queue position, partial fills, rejected orders, outages, and rule changes. Past or hypothetical performance does not guarantee future results. Aether does not guarantee any return, win rate, availability, execution price, risk-adjusted result, account qualification, or preservation of capital. + +Users may not market an ATS output, Nano strategy, or Aether feature with false or misleading statements, fabricated performance, guaranteed returns, or an implication that Aether, a regulator, a broker, or a venue approved the strategy or user. + +## 5. Financial and market risk + +Trading can result in rapid and total loss. Futures, options, short sales, leveraged products, margin, digital assets, thin markets, and volatile instruments can create losses exceeding the amount deposited or expected. Stops and limits may not execute at the requested price. Markets, brokers, exchanges, datafeeds, networks, models, browsers, and local devices can fail or behave differently from tests. + +The user accepts these risks and will not use money the user cannot afford to lose. ATS is not a substitute for professional advice from appropriately licensed persons who understand the user's circumstances. + +## 6. Legal, regulatory, venue, and contractual compliance + +The user is responsible for determining whether ATS use is lawful and permitted in every relevant jurisdiction and account. Technology does not displace existing securities, commodities, derivatives, money-transmission, sanctions, privacy, consumer-protection, recordkeeping, supervision, licensing, tax, employment, fiduciary, or market-conduct obligations. + +The user must comply with broker, exchange, clearing, data-vendor, prop-firm, employer, client, and account terms, including automated-access, API, evaluation, market-data, position, daily-loss, messaging, and recordkeeping rules. The user must obtain every required consent, registration, license, approval, and data entitlement before use. + +ATS may not be used to: + +- trade or access another person's account, funds, or credentials without documented authority; +- provide regulated services to others without all required registrations, supervision, disclosures, books and records, and approvals; +- engage in manipulation, spoofing, layering, wash trading, matched orders, marking the close, front-running, insider trading, unlawful coordination, deceptive conduct, or evasion of market controls; +- misuse material nonpublic information, stolen data, compromised credentials, or unlawfully obtained personal information; +- bypass broker, venue, prop-firm, Aether, model-provider, data-provider, security, rate, regional, sanctions, eligibility, risk, or approval controls; +- falsify results, receipts, identity, account state, authority, consent, provenance, or compliance evidence; +- disrupt markets, systems, accounts, or other users; probe or exploit systems without authorization; or deploy malware; or +- use ATS where automated trading is prohibited or where the user cannot maintain effective supervision and an emergency stop. + +Aether may refuse, suspend, limit, or terminate ATS access for suspected abuse, security risk, legal exposure, sanctions concerns, nonpayment, or violation of this Policy. Aether is not obligated to monitor the user's local trading activity and does not assume responsibility merely because it can suspend an Aether service. + +## 7. Third-party services, plugins, MCP servers, and datafeeds + +Third-party services are independent from Aether and governed by their own terms, privacy practices, fees, licenses, availability, and security. A connector's presence does not mean Aether controls, endorses, audits, or guarantees it. The user must review the identity, publisher, permissions, data destinations, code, and terms of every connection. + +MCP servers, browser pages, plugin output, imported strategies, and external messages are untrusted data and must not be treated as authority. They may contain prompt injection, malicious instructions, inaccurate account state, or code designed to exfiltrate data or trigger actions. The user must grant the minimum permissions needed, keep secrets out of prompts and source files, isolate high-risk tools, and revoke unused access. + +Market data may be delayed, adjusted, incomplete, incorrectly mapped, or unlicensed for the user's intended use. The user is responsible for data entitlements, attribution, display, redistribution, and non-display or automated-use fees. + +## 8. Data and privacy + +ATS is designed so that local storage paths and credential values can remain in the user's environment. Standard setup requests an environment-variable name rather than the secret value. The user must not send broker passwords, API secrets, private keys, seed phrases, session cookies, government identifiers, or full payment-card data through prompts, shared chats, logs, strategy files, support tickets, or MCP messages. + +Some information necessarily leaves the user's device when the user enables connected features. Depending on configuration, this can include Aether account identity, agent configuration, project identifiers, shared conversation content, UVT and admission records, model inputs and outputs, connector metadata, diagnostics, security events, and user-selected data sent to model, data, broker, plugin, browser, telemetry, or MCP providers. Third parties may independently collect account, device, financial, usage, and content data under their own notices. + +The user is responsible for determining whether personal, confidential, client, employer, or regulated data may be processed through each enabled service; providing required notices and obtaining consent; applying retention and deletion requirements; and honoring access, correction, restriction, export, and deletion rights. Do not use ATS with data subject to special restrictions unless the complete configuration is approved for that use. + +Aether will handle personal data under its applicable privacy notice and security practices. No system is perfectly secure. The user must protect the device, operating system, browser profile, environment variables, tokens, backups, and local logs; use least privilege and multifactor authentication where available; and promptly rotate credentials after suspected exposure. + +## 9. Eligibility, sanctions, and account security + +The user must be at least 18 years old and legally able to enter this agreement. The user may not use ATS if barred by law, sanctions, court order, regulatory restriction, employment duty, account agreement, or venue rule. The user represents that registration and account information is accurate and that the user is not using ATS for a prohibited person or jurisdiction. + +The user is responsible for activity under the user's accounts, devices, agents, tokens, and credentials, except to the extent caused by Aether's breach of a non-waivable legal duty. Credentials may not be shared or embedded in strategy source. Suspected compromise must be addressed immediately by stopping agents, revoking connector and broker access, rotating credentials, and reviewing venue records. + +## 10. Software changes, availability, and security + +Models, strategies, dependencies, APIs, broker interfaces, browser layouts, rules, and markets change. Aether may add, remove, modify, or deprecate features and may require renewed acceptance after a material Policy change. The user must revalidate configuration and risk controls after updates. + +ATS may be unavailable, interrupted, rate-limited, inaccurate, or discontinued. The user must not rely on ATS as the only method to monitor, cancel, close, or protect a position. The user must maintain independent access to the broker or venue. + +Good-faith security research must follow Aether's published security process and applicable law. Users may not expose another person's data or conduct testing against production accounts or markets without express authorization. + +## 11. Disclaimers + +TO THE MAXIMUM EXTENT PERMITTED BY LAW, ATS, NANO STRATEGIES, AGENT OUTPUTS, CONNECTORS, DATA, AND DOCUMENTATION ARE PROVIDED "AS IS" AND "AS AVAILABLE." AETHER DISCLAIMS IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, NON-INFRINGEMENT, ACCURACY, QUIET ENJOYMENT, AND RESULTS. AETHER DOES NOT WARRANT THAT ATS WILL BE SECURE, UNINTERRUPTED, ERROR-FREE, COMPLIANT FOR THE USER'S PARTICULAR USE, OR CAPABLE OF PREVENTING LOSSES. + +Nothing in this Policy excludes a warranty, duty, remedy, or liability that cannot lawfully be excluded. If Aether is found to be acting in a regulated capacity in a particular relationship, any non-waivable obligations of that capacity control over inconsistent language here. + +## 12. Limitation of liability + +TO THE MAXIMUM EXTENT PERMITTED BY LAW, AETHER AND ITS AFFILIATES, PERSONNEL, LICENSORS, AND CONTRIBUTORS WILL NOT BE LIABLE FOR INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, PUNITIVE, OR CONSEQUENTIAL DAMAGES; LOST PROFITS, REVENUE, DATA, GOODWILL, OR OPPORTUNITY; OR TRADING, INVESTMENT, POSITION, MARGIN, LIQUIDATION, TAX, PENALTY, SLIPPAGE, MISSED-TRADE, DUPLICATE-ORDER, ACCOUNT, PROP-FIRM, DATA, CONNECTOR, OR THIRD-PARTY LOSSES ARISING FROM OR RELATED TO ATS. + +TO THE MAXIMUM EXTENT PERMITTED BY LAW, AETHER'S AGGREGATE LIABILITY ARISING FROM ATS WILL NOT EXCEED THE GREATER OF US$100 OR THE AMOUNT THE USER PAID AETHER SPECIFICALLY FOR ATS DURING THE 12 MONTHS BEFORE THE EVENT GIVING RISE TO THE CLAIM. This limitation applies across theories of liability and does not expand any remedy available under the applicable Aether terms. It does not apply where prohibited by law or to liability that cannot be limited. + +## 13. Indemnity + +To the extent permitted by law, the user will defend, indemnify, and hold harmless Aether and its affiliates, personnel, licensors, and contributors from third-party claims, losses, penalties, and reasonable costs arising from the user's accounts, trading, data, strategies, connectors, clients, regulatory status, violation of law or third-party terms, or breach of this Policy. This does not require indemnification for Aether's own fraud, willful misconduct, or liability that cannot lawfully be shifted. + +## 14. Records and evidence + +The user authorizes Aether Agent to store a local consent receipt containing the Policy version and digest, acceptance time, and a pseudonymous account-scope digest. The receipt is evidence of acceptance only. It is not trading authority and must not contain broker credentials. + +The user should retain the Policy version, configuration, strategy source and digest, permissions, approvals, logs, receipts, broker records, and incident records required for the user's own legal and contractual obligations. Agent statements and local journals do not replace broker or venue records. + +## 15. Changes, rejection, and withdrawal + +Aether may update this Policy prospectively. A material update may require a new `1 — Accept` decision before continued ATS setup or use. The version and effective date identify the accepted text. + +Selecting **2 — Reject** stops setup. A user who previously accepted may stop using ATS, disconnect services, revoke credentials, and uninstall the software. Withdrawal does not reverse completed trades, third-party processing, accrued charges, or obligations that by their nature survive termination. + +## 16. Relationship to other terms; severability + +Licenses, payment, termination, governing law, dispute resolution, and other general service terms remain governed by the applicable Aether terms. This Policy does not create a partnership, agency, employment, fiduciary, advisory, brokerage, custody, or joint-venture relationship. The agent software acts as a tool configured by the user; it does not have legal personhood or independent authority. + +If a provision is unenforceable, it will be enforced to the maximum lawful extent and the remaining provisions will continue. Aether's failure to enforce a provision is not a waiver. + +## 17. Required acknowledgement + +By selecting **1 — Accept**, the user confirms all of the following: + +1. I read and agree to this Policy and can enter a binding agreement. +2. I understand ATS can act autonomously and can cause rapid, substantial, or total financial loss. +3. I understand execution occurs through my environment and connections, under permissions I control. +4. I remain responsible for supervision, risk limits, reconciliation, legal compliance, third-party terms, and every decision to use real capital. +5. I understand Aether does not guarantee returns and that strategies, simulations, model outputs, and agent messages can be wrong. +6. I will not provide secrets through prompts or use ATS for prohibited, deceptive, manipulative, unauthorized, or unlawful activity. +7. I understand acceptance alone grants no broker, account, or trading authority. + +Choose **1** only if every acknowledgement is true. Otherwise choose **2**. + +--- + +This Policy is a product safeguard and contract draft, not a substitute for review by qualified counsel in every jurisdiction where ATS is offered or used. diff --git a/README.md b/README.md index b508df8..0a5eea0 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package.json b/package.json index 0eeb647..7dc83e4 100644 --- a/package.json +++ b/package.json @@ -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" ], diff --git a/scripts/release-candidate.ts b/scripts/release-candidate.ts index be895b3..7bb8ac3 100644 --- a/scripts/release-candidate.ts +++ b/scripts/release-candidate.ts @@ -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. diff --git a/scripts/release-truth.ts b/scripts/release-truth.ts index 0f1818d..acf29d2 100644 --- a/scripts/release-truth.ts +++ b/scripts/release-truth.ts @@ -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 diff --git a/scripts/verify-production.ts b/scripts/verify-production.ts index 796eada..9775502 100644 --- a/scripts/verify-production.ts +++ b/scripts/verify-production.ts @@ -38,6 +38,7 @@ export interface PackReport { } const REQUIRED_ROOT_FILES = new Set([ + "ATS_ACCEPTABLE_USE_POLICY.md", "COMMANDS.md", "LICENSE", "NOTICE.md", @@ -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 { diff --git a/scripts/vps-ci-release-check.mjs b/scripts/vps-ci-release-check.mjs index 147a666..b60d38b 100644 --- a/scripts/vps-ci-release-check.mjs +++ b/scripts/vps-ci-release-check.mjs @@ -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", diff --git a/src/commands/ats_agent.ts b/src/commands/ats_agent.ts index fe6dea0..bd6b159 100644 --- a/src/commands/ats_agent.ts +++ b/src/commands/ats_agent.ts @@ -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"; @@ -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; } async function loadPackage(): Promise { @@ -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(); @@ -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); } diff --git a/src/commands/ats_policy.ts b/src/commands/ats_policy.ts new file mode 100644 index 0000000..dafbcf3 --- /dev/null +++ b/src/commands/ats_policy.ts @@ -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 { + 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; + 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 { + 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 { + 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 { + 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 ` 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(); + } +} diff --git a/test/ats_agent.test.ts b/test/ats_agent.test.ts index 39d34a4..dbe69f1 100644 --- a/test/ats_agent.test.ts +++ b/test/ats_agent.test.ts @@ -94,6 +94,25 @@ test("ATS profile stays an observable, zero-budget draft and never grants tradin assert.throws(() => atsManagedConfig(" "), /name/); }); +test("rejecting the ATS policy stops before draft, storage, strategy and connector setup", async () => { + await fixture(async (dir) => { + let setupCalls = 0; + let loads = 0; + let output = ""; + const hooks = createAtsHooks({ + root: dir, + output: text => { output += text; }, + acceptPolicy: async () => false, + setup: async () => { setupCalls++; return { memoryGb: 5, strategiesDirectory: join(dir, "strategies") }; }, + load: async () => { loads++; return fakePackage(); }, + }); + assert.equal(await hooks.createATS!(context(), "Market Scout"), 2); + assert.equal(setupCalls, 0); + assert.equal(loads, 0); + assert.match(output, /No agent, storage, strategy, datafeed, browser, plugin, or MCP setup was created/); + }); +}); + test("ordinary managed agents never load local ATS dependencies", async () => { await fixture(async (dir) => { let loads = 0; @@ -122,7 +141,7 @@ test("setup failure preserves the created Cloud draft and never saves a ready lo await fixture(async (dir) => { let output = ""; let scans = 0; - const hooks = createAtsHooks({ root: dir, output: (text) => { output += text; }, setup: async () => ({ memoryGb: 5, strategiesDirectory: join(dir, "strategies") }), + const hooks = createAtsHooks({ root: dir, output: (text) => { output += text; }, acceptPolicy: async () => true, setup: async () => ({ memoryGb: 5, strategiesDirectory: join(dir, "strategies") }), load: async () => fakePackage({ initializeMemory: async () => ({ state: "unavailable" }), scanStrategies: async () => { scans++; return {}; } }) }); await withCreate(async () => { assert.equal(await hooks.createATS!(context(), "Market Scout"), 1); }); assert.match(output, /agent is saved/); @@ -137,7 +156,7 @@ test("setup failure preserves the created Cloud draft and never saves a ready lo test("ATS setup surfaces a safe actionable memory-engine failure", async () => { await fixture(async (dir) => { let output = ""; - const hooks = createAtsHooks({ root: dir, output: (text) => { output += text; }, setup: async () => ({ memoryGb: 5, strategiesDirectory: join(dir, "strategies") }), + const hooks = createAtsHooks({ root: dir, output: (text) => { output += text; }, acceptPolicy: async () => true, setup: async () => ({ memoryGb: 5, strategiesDirectory: join(dir, "strategies") }), load: async () => fakePackage({ initializeMemory: async () => ({ state: "unavailable", code: "CONTEXT_ENGINE_UNAVAILABLE", message: "Install the pinned aether-context engine, or select its Python interpreter." }) }) }); await withCreate(async () => { assert.equal(await hooks.createATS!(context(), "Market Scout"), 1); }); assert.match(output, /CONTEXT_ENGINE_UNAVAILABLE/); @@ -149,7 +168,7 @@ test("ATS setup surfaces a safe actionable memory-engine failure", async () => { test("successful setup stores an account-scoped binding only after memory and strategy checks", async () => { await fixture(async (dir) => { const calls: string[] = []; - const hooks = createAtsHooks({ root: dir, env: {}, output: () => {}, setup: async () => ({ memoryGb: 5, strategiesDirectory: join(dir, "strategies") }), + const hooks = createAtsHooks({ root: dir, env: {}, output: () => {}, acceptPolicy: async () => true, setup: async () => ({ memoryGb: 5, strategiesDirectory: join(dir, "strategies") }), load: async () => fakePackage({ initializeMemory: async (input) => { calls.push("memory"); assert.equal(input.agentId, ID); assert.equal(input.sizeGb, 5); return memoryReceipt(input); }, scanStrategies: async () => { calls.push("scan"); await assert.rejects(readFile(settingsPath(dir)), { code: "ENOENT" }); return { state: "scanned", strategies: [] }; } }) }); await withCreate(async () => { assert.equal(await hooks.createATS!(context(), "Market Scout"), 0); }); @@ -167,7 +186,7 @@ test("empty first setup installs the reviewed Nano starter pack and journals the await fixture(async dir => { let scans = 0; let installs = 0; const events: string[] = []; let output = ""; const hooks = createAtsHooks({ root: dir, env: {}, output: text => { output += text; }, - setup: async () => ({ memoryGb: 5, strategiesDirectory: join(dir, "strategies") }), + acceptPolicy: async () => true, setup: async () => ({ memoryGb: 5, strategiesDirectory: join(dir, "strategies") }), load: async () => fakePackage({ scanStrategies: async () => ({ state: "scanned", compiler: "unavailable", strategies: scans++ ? [{ file: "risk--stale_data_halt.nano", state: "unavailable" }] : [] }), installBundledStrategies: async ({ directory }) => { installs++; return { revision: "76c91e4b926c0aa8416cbb6b8724031d8141a8d9", installed: [{ id: "risk/stale_data_halt", file: join(directory, "risk--stale_data_halt.nano") }], execution_enabled: false, permission_granted: false }; }, @@ -206,7 +225,7 @@ test("a symlink strategy folder leaves the Cloud draft intact and skips strategy const target = join(dir, "target"); await mkdir(target); const strategies = join(dir, "strategies"); await symlink(target, strategies, "dir"); let scanned = false; - const hooks = createAtsHooks({ root: join(dir, "settings"), output: () => {}, setup: async () => ({ memoryGb: 5, strategiesDirectory: strategies }), + const hooks = createAtsHooks({ root: join(dir, "settings"), output: () => {}, acceptPolicy: async () => true, setup: async () => ({ memoryGb: 5, strategiesDirectory: strategies }), load: async () => fakePackage({ scanStrategies: async () => { scanned = true; return {}; } }) }); await withCreate(async () => { assert.equal(await hooks.createATS!(context(), "Market Scout"), 1); }); assert.equal(scanned, false); @@ -220,7 +239,7 @@ test("symlink memory folders are refused before native initialization on setup a const memory = join(dir, "memory"); await symlink(target, memory, "dir"); const root = join(dir, "settings"); let initialized = 0; - const hooks = createAtsHooks({ root, output: () => {}, setup: async () => ({ memoryDirectory: memory, memoryGb: 5, strategiesDirectory: join(dir, "strategies") }), + const hooks = createAtsHooks({ root, output: () => {}, acceptPolicy: async () => true, setup: async () => ({ memoryDirectory: memory, memoryGb: 5, strategiesDirectory: join(dir, "strategies") }), load: async () => fakePackage({ initializeMemory: async () => { initialized++; return { state: "ready" }; } }) }); await withCreate(async () => { assert.equal(await hooks.createATS!(context(), "Market Scout"), 1); }); await binding(root, { memory_directory: memory }); @@ -603,7 +622,7 @@ test("setup checkpoint resumes a verified custom memory location after strategy const memory = join(dir, "custom-memory"); const pack = fakePackage({ scanStrategies: async () => { if (++scans === 1) throw new Error("scan failed"); return { state: "scanned", strategies: [] }; } }); const deps = { root: dir, env: {}, output: () => {}, load: async () => pack, - setup: async () => { questions++; return { memoryDirectory: memory, memoryGb: 5, strategiesDirectory: join(dir, "strategies") }; } }; + acceptPolicy: async () => true, setup: async () => { questions++; return { memoryDirectory: memory, memoryGb: 5, strategiesDirectory: join(dir, "strategies") }; } }; await withCreate(async () => { assert.equal(await createAtsHooks(deps).createATS!(context(), "Market Scout"), 1); }); const canonicalMemory = await realpath(memory); const pending = JSON.parse(await readFile(settingsPath(dir) + ".pending", "utf8")); @@ -634,7 +653,7 @@ test("setup persists provider configuration through the ATS validator without cl const real = await import(packageName); let output = ""; const hooks = createAtsHooks({ root: dir, env: {}, output: text => { output += text; }, - setup: async () => ({ memoryGb: 5, strategiesDirectory: join(dir, "strategies"), + acceptPolicy: async () => true, setup: async () => ({ memoryGb: 5, strategiesDirectory: join(dir, "strategies"), dataStream: { provider: "polygon", endpoint: null, api_key_env: "POLYGON_API_KEY", symbols: ["AAPL", "MSFT"] } }), load: async () => fakePackage({ loadSettings: real.loadSettings, saveSettings: real.saveSettings, dataStreamStatus: real.dataStreamStatus }), }); @@ -651,7 +670,7 @@ test("setup persists provider configuration through the ATS validator without cl test("memory and strategy setup receive cancellation and cannot publish a ready binding after abort", async () => { await fixture(async (dir) => { const controller = new AbortController(); let scans = 0; - const hooks = createAtsHooks({ root: dir, output: () => {}, setup: async () => ({ memoryGb: 5, strategiesDirectory: join(dir, "strategies") }), + const hooks = createAtsHooks({ root: dir, output: () => {}, acceptPolicy: async () => true, setup: async () => ({ memoryGb: 5, strategiesDirectory: join(dir, "strategies") }), load: async () => fakePackage({ initializeMemory: async input => { assert.equal(input.signal, controller.signal); controller.abort(); return memoryReceipt(input); @@ -694,7 +713,7 @@ test("fresh account identity separates memory bindings after sign-in changes and await fixture(async (dir) => { await binding(dir); let loads = 0; let subject = SUBJECT; let identityCalls = 0; globalThis.fetch = (async () => { identityCalls++; return new Response(JSON.stringify({ schema_version: "aether.terminal-account/1", account_subject: subject })); }) as typeof fetch; - const hooks = createAtsHooks({ root: dir, env: {}, output: () => {}, setup: async () => { throw new Error("new account requires its own setup"); }, load: async () => { loads++; return fakePackage(); } }); + const hooks = createAtsHooks({ root: dir, env: {}, output: () => {}, acceptPolicy: async () => true, setup: async () => { throw new Error("new account requires its own setup"); }, load: async () => { loads++; return fakePackage(); } }); const ctx = context(); await hooks.onChatCommand!(ctx, agent(), "/ats status"); assert.equal(loads, 1); subject = "22222222-2222-4222-8222-222222222222"; @@ -707,7 +726,7 @@ test("fresh account identity separates memory bindings after sign-in changes and test("ATS memory and strategy setup receive the operation cancellation signal and verified account scope", async () => { await fixture(async (dir) => { const controller = new AbortController(); const calls: string[] = []; - const hooks = createAtsHooks({ root: dir, env: {}, output: () => {}, setup: async () => ({ memoryGb: 5, strategiesDirectory: join(dir, "strategies") }), load: async () => fakePackage({ + const hooks = createAtsHooks({ root: dir, env: {}, output: () => {}, acceptPolicy: async () => true, setup: async () => ({ memoryGb: 5, strategiesDirectory: join(dir, "strategies") }), load: async () => fakePackage({ initializeMemory: async input => { assert.equal(input.signal, controller.signal); assert.deepEqual(input.ownerScope, ACCOUNT); calls.push("memory"); return memoryReceipt(input); }, scanStrategies: async input => { assert.equal(input.signal, controller.signal); calls.push("strategies"); return { state: "scanned", strategies: [] }; }, }) }); diff --git a/test/ats_policy.test.ts b/test/ats_policy.test.ts new file mode 100644 index 0000000..ca9512d --- /dev/null +++ b/test/ats_policy.test.ts @@ -0,0 +1,73 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PassThrough, Writable } from "node:stream"; +import { + ATS_POLICY_SHA256, + atsPolicyReceiptPath, + requireAtsPolicyAcceptance, +} from "../src/commands/ats_policy.js"; + +const account = { cloudOrigin: "https://example.test", accountSubject: "11111111-1111-4111-8111-111111111111" }; + +function terminal(answer: string): { input: PassThrough & { isTTY: true }; out: Writable; rendered: () => string } { + const input = Object.assign(new PassThrough(), { isTTY: true as const }); + let text = ""; + let answered = false; + const out = new Writable({ write(chunk, _encoding, done) { + text += String(chunk); + if (!answered && text.includes("Choice [2]:")) { answered = true; setImmediate(() => input.write(answer + "\r")); } + done(); + } }); + return { input, out, rendered: () => text }; +} + +test("published ATS policy digest matches the consent gate", async () => { + const checkoutText = await readFile("ATS_ACCEPTABLE_USE_POLICY.md", "utf8"); + // Git may materialize Markdown with CRLF on Windows. Consent binds the + // canonical LF policy text, not the checkout's platform-specific encoding. + const canonical = checkoutText.replace(/\r\n/g, "\n"); + assert.equal(createHash("sha256").update(canonical).digest("hex"), ATS_POLICY_SHA256); + const windowsCheckout = canonical.replace(/\n/g, "\r\n"); + assert.equal(createHash("sha256").update(windowsCheckout.replace(/\r\n/g, "\n")).digest("hex"), ATS_POLICY_SHA256); +}); + +test("choice 2 rejects without writing a consent receipt", async () => { + const root = await mkdtemp(join(tmpdir(), "aether-ats-policy-")); + const io = terminal("2"); + try { + assert.equal(await requireAtsPolicyAcceptance({ root, account, input: io.input, out: io.out }), false); + await assert.rejects(readFile(atsPolicyReceiptPath(root, account)), { code: "ENOENT" }); + assert.match(io.rendered(), /does not connect a broker or grant this agent trading authority/i); + } finally { io.input.destroy(); await rm(root, { recursive: true, force: true }); } +}); + +test("choice 1 writes a pseudonymous, non-authorizing receipt and current receipt avoids reprompt", async () => { + const root = await mkdtemp(join(tmpdir(), "aether-ats-policy-")); + const first = terminal("1"); + try { + assert.equal(await requireAtsPolicyAcceptance({ root, account, input: first.input, out: first.out }), true); + const receipt = JSON.parse(await readFile(atsPolicyReceiptPath(root, account), "utf8")); + assert.equal(receipt.policy_sha256, ATS_POLICY_SHA256); + assert.equal(receipt.decision, "accepted"); + assert.equal(receipt.grants_trading_authority, false); + assert.equal(receipt.account_subject, undefined); + assert.equal(receipt.cloud_origin, undefined); + const second = terminal("2"); + assert.equal(await requireAtsPolicyAcceptance({ root, account, input: second.input, out: second.out }), true); + assert.equal(second.rendered(), ""); + second.input.destroy(); + } finally { first.input.destroy(); await rm(root, { recursive: true, force: true }); } +}); + +test("non-interactive setup cannot manufacture policy consent", async () => { + const root = await mkdtemp(join(tmpdir(), "aether-ats-policy-")); + const input = Object.assign(new PassThrough(), { isTTY: false }); + try { + await assert.rejects(requireAtsPolicyAcceptance({ root, account, input }), /interactive terminal/); + await assert.rejects(readFile(atsPolicyReceiptPath(root, account)), { code: "ENOENT" }); + } finally { input.destroy(); await rm(root, { recursive: true, force: true }); } +}); diff --git a/test/ats_repl_setup.test.ts b/test/ats_repl_setup.test.ts index e600831..546d985 100644 --- a/test/ats_repl_setup.test.ts +++ b/test/ats_repl_setup.test.ts @@ -19,6 +19,7 @@ test("coding REPL slash setup never queues wizard answers and restores the promp const paths = []; globalThis.fetch = async url => { const path = new URL(String(url)).pathname; paths.push(path); + if (path.endsWith('/agent/managed/identity')) return new Response(JSON.stringify({schema_version:'aether.terminal-account/1',account_subject:'11111111-1111-4111-8111-111111111111'})); if (!path.endsWith('/models')) throw new Error('Unexpected coding or create request'); return new Response(JSON.stringify({models:[], orchestrators:[], default:'', tier:'free'})); }; @@ -33,6 +34,7 @@ test("coding REPL slash setup never queues wizard answers and restores the promp const sent = new Set(); const replies: Array<[string, string]> = [ ["Type a prompt,", "/agent-create ATS Atlas\r"], + ["Choice [2]:", "1\r"], ["Memory drive/folder", join(root, "memory") + "\r"], ["Memory size in GiB", "5\r"], ["Strategy folder", join(root, "strategies") + "\r"], @@ -54,7 +56,7 @@ test("coding REPL slash setup never queues wizard answers and restores the promp assert.ok(match, output + errors); const result = JSON.parse(match[1]!); assert.equal(result.code, 0); - assert.ok(result.paths.every((path: string) => path.endsWith("/models")), JSON.stringify(result.paths)); + assert.ok(result.paths.every((path: string) => path.endsWith("/models") || path.endsWith("/agent/managed/identity")), JSON.stringify(result.paths)); assert.doesNotMatch(output, /Queued|Running:/); assert.equal(sent.size, replies.length); } finally { clearTimeout(deadline); child.kill(); await rm(root, { recursive: true, force: true }); } diff --git a/test/production_hardening.test.ts b/test/production_hardening.test.ts index 8fb1b58..0d8a9ae 100644 --- a/test/production_hardening.test.ts +++ b/test/production_hardening.test.ts @@ -19,7 +19,7 @@ const manifest = { types: "dist/src/index.d.ts", bin: { aether: "dist/src/main.js" }, files: [ - "dist/src", "README.md", "assets/aether-agent-hero.png", "COMMANDS.md", "LICENSE", "NOTICE.md", + "dist/src", "ATS_ACCEPTABLE_USE_POLICY.md", "README.md", "assets/aether-agent-hero.png", "COMMANDS.md", "LICENSE", "NOTICE.md", "docs/generated/commands.md", "docs/generated/model-catalogue.md", "docs/model-catalogue/catalogue.json", "docs/model-catalogue/index.html", "packages/ats-skills", "packages/ats-skills-source.json", @@ -40,6 +40,7 @@ const pack: PackReport = { entryCount: 8, files: [ "COMMANDS.md", + "ATS_ACCEPTABLE_USE_POLICY.md", "LICENSE", "NOTICE.md", "README.md", diff --git a/test/release_coherence.test.ts b/test/release_coherence.test.ts index f52b3a2..fd852bd 100644 --- a/test/release_coherence.test.ts +++ b/test/release_coherence.test.ts @@ -530,7 +530,7 @@ test( { timeout: 120_000 }, () => { // The source checkout's dist/ is NOT the package: the files allowlist is - // dist/src plus four docs, so dist/scripts and dist/test exist on disk and + // dist/src plus reviewed public docs, so dist/scripts and dist/test exist on disk and // ship to nobody. Ask npm what would actually be packed. const packed = currentPackReport(); const paths = new Set(packed.files.map((file) => file.path.replaceAll("\\", "/")));