diff --git a/.env.example b/.env.example index 7d362b6..cb32afe 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,9 @@ CHARTER_API_PORT=4477 # Leave blank for local development. Set this before exposing the API on a # public host, since every request then needs a matching X-Charter-Api-Key header. CHARTER_API_KEY= +# Set to true to refuse the shared key above and accept only agents registered with +# charter agent add, each with its own key bound to specific mandates. +CHARTER_REQUIRE_AGENT_KEYS=false # A different credential, for deciding rather than proposing: approve, reject, # halt, resume. Agents must not hold it. If unset, those API endpoints are # disabled and decisions are made through the CLI. diff --git a/README.md b/README.md index 4d531e5..b5a7f3a 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,26 @@ flowchart TD A proposal only ever reaches a real exchange through the execution adapter, and the execution adapter only ever runs on a PASS or an approved ESCALATE. A VETO stops at the policy engine, which is why a vetoed proposal has no execution entry in the audit log at all, not a failed one, a missing one. +## Agents + +Each agent gets its own key, bound to the mandates it is allowed to use: + +```bash +npx tsx src/index.ts agent add trader-1 --mandate +npx tsx src/index.ts agent list +npx tsx src/index.ts agent rotate trader-1 +npx tsx src/index.ts agent revoke trader-1 +``` + +The key is printed once and only its hash is stored. An agent sends it as `X-Charter-Api-Key`. From then on: + +- Its identity is the key's, not whatever the request body says. A body naming a different agent is refused. +- It can only propose against its own mandates. +- It can only read its own results. Another agent's are reported as not found. +- Rotating replaces the key at once. Revoking stops it at once, and revoking the last agent does not reopen the API to unauthenticated callers. + +The single shared `CHARTER_API_KEY` still works for a simple setup. Set `CHARTER_REQUIRE_AGENT_KEYS=true` to refuse it and accept only registered agents. + ## Human approval An ESCALATE is not confirmed by a flag on the same command. It opens a pending approval that someone other than the proposer has to grant, and the proposing agent can never grant it: @@ -67,7 +87,7 @@ npx tsx src/index.ts resume CHARTER only enforces anything if the agent has to go through it. It is a real boundary when CHARTER is the only holder of the exchange credentials and agents hold nothing but the proposing key. If an agent has its own Binance key, it can trade without CHARTER and nothing here stops it. Enforcement is a deployment property, not something the code can guarantee by itself. -What that setup protects against: an agent exceeding its limits, an agent approving its own escalation, replaying or double-executing an approval, acting on a stale approval, and tampering with the record afterward (detectable). What it does not protect against: someone with write access to the audit file who also recomputes every hash, a compromised CHARTER host, or an approver who approves something bad. The CLI runs with local access to the log, so the OS username is the identity it records. The API identity is whatever the holder of the approver key claims in `X-Charter-Approver`. Per-agent keys, and binding each agent to its own mandate, are not built yet; today there is one shared agent key. +What that setup protects against: an agent exceeding its limits, an agent approving its own escalation, replaying or double-executing an approval, acting on a stale approval, and tampering with the record afterward (detectable). What it does not protect against: someone with write access to the audit file who also recomputes every hash, a compromised CHARTER host, or an approver who approves something bad. The CLI runs with local access to the log, so the OS username is the identity it records. The API identity is whatever the holder of the approver key claims in `X-Charter-Approver`. There is still one shared approver key, so approvers are told apart by the name they claim, not by separate credentials. The full list of what is and is not defended against is in [docs/THREAT_MODEL.md](docs/THREAT_MODEL.md). ## Verdict reasons diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md new file mode 100644 index 0000000..fe85717 --- /dev/null +++ b/docs/THREAT_MODEL.md @@ -0,0 +1,57 @@ +# Threat model + +What CHARTER is meant to protect, who it assumes can do what, and where the protection stops. This is written to be checked against the code, not to reassure. + +## What is being protected + +An exchange account's funds, from an autonomous agent that trades on it. The specific risks are an agent placing trades outside what its operator meant to allow, an agent talking its way past a human check, and the record of what happened being changed afterward. + +## Actors + +- **Agent.** Software that proposes trades. Untrusted. It may be buggy, manipulated by its inputs, or hostile. +- **Approver / operator.** A person who decides escalations and can halt trading. Trusted to act in good faith, and identified. +- **CHARTER host.** The machine running CHARTER, holding the audit log and the exchange credentials. Trusted. +- **The exchange.** Trusted to execute correctly. Out of scope. + +## Trust boundary + +CHARTER enforces limits only on trades that go through it. For that to mean anything, CHARTER must be the only holder of the exchange credentials. An agent that holds its own exchange key can trade without CHARTER, and nothing in this codebase can stop it. This is a property of how it is deployed, not something the software can guarantee. If you are not prepared to keep exchange credentials away from agents, CHARTER is advisory, not enforcing. + +## Controls, and what each defends against + +| Threat | Control | Where | +| --- | --- | --- | +| Agent exceeds size, symbol, side, spend, slippage, or drawdown limits | Six rules evaluated against a live simulation; any violation is a VETO and nothing reaches the exchange | `src/policy/engine.ts`, `src/policy/rules/` | +| Agent reaches the exchange without a passing verdict | Execution has a single entry point that refuses anything but PASS or an approved ESCALATE; a test asserts a VETO never calls the venue | `src/execution/adapter.ts` | +| Agent approves its own escalation | Deciding needs a separate credential the agent does not hold; the approver may not be the proposing agent | `src/api/server.ts`, `src/approval/service.ts` | +| Agent impersonates another agent | Identity comes from the agent's own key, never from the request body; a body naming another agent is refused | `src/api/server.ts` | +| Agent uses a mandate it was not given | Each agent is bound to specific mandates at registration | `src/agents/` | +| Agent reads another agent's results | Status and escalation lookups are scoped to the caller; someone else's is reported as not found | `src/api/server.ts` | +| A leaked or retired agent key keeps working | Keys are revocable and rotatable with immediate effect; only a hash is stored, and the key is shown once | `src/agents/service.ts` | +| Revoking the last agent reopens the API to everyone | Once any agent has been registered, an unauthenticated call is refused even if all are revoked | `registryInUse` in `src/agents/service.ts` | +| Approval replayed, or two approvals race | An approval is granted once; a per-id guard covers simultaneous requests | `src/approval/service.ts` | +| Approval acted on after conditions changed | The proposal is evaluated again, with fresh market data, spend, drawdown, and kill switch, at the moment of approval | `src/approval/service.ts` | +| Approval acted on after it went stale | Approvals expire | `src/approval/state.ts` | +| Trading must stop now | Kill switch vetoes everything, including an already-approved trade not yet executed | `src/policy/rules/killSwitch.ts` | +| The record is edited after the fact | Hash-chained log; edits, reordering, and deletions are detected by `audit verify` | `src/audit/log.ts` | +| Acting on a tampered record | The chain is verified before an approval is acted on | `src/approval/service.ts` | +| Timing attacks on key comparison | Constant-time comparison for every credential | `constantTimeEquals` | +| Error responses leak internals | Malformed input returns a plain 400; unexpected errors return no stack trace or paths | `src/api/server.ts` | + +## What this does not protect against + +- **An agent with its own exchange credentials.** See the trust boundary above. +- **A compromised CHARTER host.** Whoever controls the machine controls the audit file, the keys, and the process. The hash chain detects editing by someone who cannot recompute it; it does not stop someone who can rewrite the whole file from the beginning. There is no external anchor for the chain. +- **A bad approver.** A person with the approver credential can approve something they should not. The approval is attributed and logged, which is accountability, not prevention. +- **Approver identity over the API is asserted, not proven.** The approver key proves someone holds it; the `X-Charter-Approver` name is whatever they claim. Separate approvers should hold separate credentials, which today means separate deployments. The CLI records the operating-system username. +- **One shared approver key.** Unlike agents, approvers do not yet each have their own key. +- **Denial of service.** There is no rate limiting on the API beyond what the mandate rules impose on trading. +- **Transport security.** The server speaks plain HTTP. Put it behind TLS if it is reachable beyond localhost. +- **Testnet only.** Everything here has been exercised against Binance Spot Testnet. The mainnet execution path has not been run. +- **Simulation is an estimate.** It walks a sampled order book and can understate the impact of a large order; it now says so when the depth ran out, but it does not predict moves between simulation and fill. + +## Assumptions worth stating + +- The audit file is only written by CHARTER. Two CHARTER processes writing the same file concurrently is not supported. +- Agent keys and the approver key are kept secret by whoever holds them. +- The system clock is roughly right; approval expiry depends on it. diff --git a/src/agents/service.ts b/src/agents/service.ts new file mode 100644 index 0000000..f9caf42 --- /dev/null +++ b/src/agents/service.ts @@ -0,0 +1,95 @@ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import { auditLog } from "../audit/log.js"; +import { deriveAgents, type AgentRecord } from "./state.js"; + +export class AgentExistsError extends Error { + constructor(public readonly agentId: string) { + super(`Agent "${agentId}" is already registered and active. Revoke or rotate it instead.`); + this.name = "AgentExistsError"; + } +} + +export class AgentNotFoundError extends Error { + constructor(public readonly agentId: string) { + super(`No active agent named "${agentId}"`); + this.name = "AgentNotFoundError"; + } +} + +const AGENT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; + +/** Compares two secrets without leaking, through timing, how much of a guess was right. */ +export function constantTimeEquals(a: string, b: string): boolean { + const ha = createHash("sha256").update(a).digest(); + const hb = createHash("sha256").update(b).digest(); + return timingSafeEqual(ha, hb); +} + +export function hashKey(key: string): string { + return createHash("sha256").update(key).digest("hex"); +} + +function newKey(): string { + return `chk_${randomBytes(32).toString("base64url")}`; +} + +export async function listAgents(): Promise { + return [...deriveAgents(await auditLog.all()).values()].sort((a, b) => a.agentId.localeCompare(b.agentId)); +} + +/** Registers an agent bound to specific mandates. Returns the key once; only its hash is stored. */ +export async function registerAgent(agentId: string, mandateIds: string[], by: string): Promise<{ agentId: string; key: string; mandateIds: string[] }> { + if (!AGENT_ID.test(agentId)) { + throw new Error("agentId must be 1 to 64 characters: letters, digits, dot, dash, underscore, starting with a letter or digit"); + } + if (mandateIds.length === 0) { + throw new Error("An agent must be bound to at least one mandate"); + } + const existing = deriveAgents(await auditLog.all()).get(agentId); + if (existing?.status === "active") throw new AgentExistsError(agentId); + + const key = newKey(); + await auditLog.append("AGENT_REGISTERED", "n/a", { agentId, keyHash: hashKey(key), mandateIds, by }); + return { agentId, key, mandateIds }; +} + +export async function revokeAgent(agentId: string, by: string): Promise { + const existing = deriveAgents(await auditLog.all()).get(agentId); + if (!existing || existing.status !== "active") throw new AgentNotFoundError(agentId); + await auditLog.append("AGENT_REVOKED", "n/a", { agentId, by }); +} + +/** Issues a new key for an active agent, keeping its mandates. The old key stops working immediately. */ +export async function rotateAgent(agentId: string, by: string): Promise<{ agentId: string; key: string; mandateIds: string[] }> { + const existing = deriveAgents(await auditLog.all()).get(agentId); + if (!existing || existing.status !== "active") throw new AgentNotFoundError(agentId); + await auditLog.append("AGENT_REVOKED", "n/a", { agentId, by }); + return registerAgent(agentId, existing.mandateIds, by); +} + +/** + * Finds the active agent a presented key belongs to. Compares against every + * active agent's hash in constant time per comparison, so response timing + * does not reveal how much of a guess was right. + */ +export async function authenticateAgent(presentedKey: string | undefined): Promise { + if (!presentedKey) return undefined; + const presented = Buffer.from(hashKey(presentedKey), "hex"); + let match: AgentRecord | undefined; + for (const agent of deriveAgents(await auditLog.all()).values()) { + if (agent.status !== "active") continue; + const stored = Buffer.from(agent.keyHash, "hex"); + if (stored.length === presented.length && timingSafeEqual(stored, presented)) match = agent; + } + return match; +} + +/** + * True once any agent has ever been registered, including ones since revoked. + * The API is only open (no key needed) on an instance that has never used + * per-agent keys. Revoking the last agent must not silently reopen it to + * everyone, so revoked agents still count here. + */ +export async function registryInUse(): Promise { + return deriveAgents(await auditLog.all()).size > 0; +} diff --git a/src/agents/state.ts b/src/agents/state.ts new file mode 100644 index 0000000..8302439 --- /dev/null +++ b/src/agents/state.ts @@ -0,0 +1,59 @@ +import type { AuditEntry } from "../audit/log.js"; + +/** + * Registered agents are derived from the audit log, like approvals and the + * kill switch. Only a SHA-256 hash of each key is ever recorded, never the + * key itself, so the log can be shared or inspected without exposing a + * credential. The key is shown once, at registration. + */ + +export type AgentStatus = "active" | "revoked"; + +export interface AgentRecord { + agentId: string; + keyHash: string; + mandateIds: string[]; + status: AgentStatus; + registeredAt: string; + registeredBy: string; + revokedAt?: string; + revokedBy?: string; +} + +interface RegisteredPayload { + agentId: string; + keyHash: string; + mandateIds: string[]; + by: string; +} + +interface RevokedPayload { + agentId: string; + by: string; +} + +export function deriveAgents(entries: AuditEntry[]): Map { + const agents = new Map(); + for (const e of entries) { + if (e.type === "AGENT_REGISTERED") { + const p = e.payload as RegisteredPayload; + agents.set(p.agentId, { + agentId: p.agentId, + keyHash: p.keyHash, + mandateIds: p.mandateIds, + status: "active", + registeredAt: e.timestamp, + registeredBy: p.by, + }); + } else if (e.type === "AGENT_REVOKED") { + const p = e.payload as RevokedPayload; + const record = agents.get(p.agentId); + if (record && record.status === "active") { + record.status = "revoked"; + record.revokedAt = e.timestamp; + record.revokedBy = p.by; + } + } + } + return agents; +} diff --git a/src/api/server.ts b/src/api/server.ts index 6560a26..a1ac2c9 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -21,9 +21,12 @@ import { releaseKillSwitch, } from "../approval/service.js"; import type { ApprovalStatus } from "../approval/state.js"; +import { authenticateAgent, constantTimeEquals, registryInUse } from "../agents/service.js"; +import type { AgentRecord } from "../agents/state.js"; const ProposeBodySchema = z.object({ - agentId: z.string().min(1), + /** Required when the caller uses the shared key or no key. For a registered agent it is ignored, and must match if given. */ + agentId: z.string().min(1).optional(), mandateId: z.string().uuid(), symbol: z.string().min(1), side: z.enum(["BUY", "SELL"]), @@ -55,20 +58,25 @@ export function startApiServer(): Server { const app = express(); app.use(express.json()); - if (!config.apiKey) { - console.log("Warning: CHARTER_API_KEY is not set. Anyone who can reach this API can submit proposals."); - } + void registryInUse().then((any) => { + if (config.requireAgentKeys && !any) { + console.log("Note: CHARTER_REQUIRE_AGENT_KEYS is set but no agents have been registered, so every proposal is refused until one is (charter agent add)."); + } else if (!config.requireAgentKeys && !config.apiKey && !any) { + console.log("Warning: no CHARTER_API_KEY is set and no agents have been registered. Anyone who can reach this API can submit proposals."); + } + }); if (!config.approverKey) { console.log("Note: CHARTER_APPROVER_KEY is not set, so the approve/reject/halt/resume endpoints are disabled. Use the CLI to decide."); } - app.use((req: Request, res: Response, next: NextFunction) => { + app.use(async (req: Request, res: Response, next: NextFunction) => { if (isApproverRoute(req.path)) { if (!config.approverKey) { res.status(403).json({ error: "Approver endpoints are disabled: CHARTER_APPROVER_KEY is not set on this server" }); return; } - if (req.get("X-Charter-Approver-Key") !== config.approverKey) { + const presentedApprover = req.get("X-Charter-Approver-Key"); + if (!presentedApprover || !constantTimeEquals(presentedApprover, config.approverKey)) { res.status(401).json({ error: "Missing or invalid X-Charter-Approver-Key header" }); return; } @@ -79,11 +87,36 @@ export function startApiServer(): Server { next(); return; } - if (config.apiKey && req.get("X-Charter-Api-Key") !== config.apiKey) { + + try { + const presented = req.get("X-Charter-Api-Key"); + + // A registered agent's own key identifies it. From here on its identity + // comes from the key, never from anything the request body claims. + const agent = await authenticateAgent(presented); + if (agent) { + res.locals.agent = agent; + next(); + return; + } + + // The single shared key from before per-agent keys existed. Refused + // outright when CHARTER_REQUIRE_AGENT_KEYS=true. + if (!config.requireAgentKeys && config.apiKey && presented && constantTimeEquals(presented, config.apiKey)) { + next(); + return; + } + + // Nothing configured at all: open, for local development. + if (!config.requireAgentKeys && !config.apiKey && !(await registryInUse())) { + next(); + return; + } + res.status(401).json({ error: "Missing or invalid X-Charter-Api-Key header" }); - return; + } catch (err) { + next(err); } - next(); }); // In-memory index of recent results for GET /status/:id. The audit log @@ -98,8 +131,26 @@ export function startApiServer(): Server { res.status(400).json({ error: "Invalid proposal", issues: parsed.error.issues }); return; } + const agent = res.locals.agent as AgentRecord | undefined; + let agentId = parsed.data.agentId; + if (agent) { + if (agentId !== undefined && agentId !== agent.agentId) { + res.status(403).json({ error: `This key belongs to agent "${agent.agentId}". It cannot propose as "${agentId}".` }); + return; + } + if (!agent.mandateIds.includes(parsed.data.mandateId)) { + res.status(403).json({ error: `Agent "${agent.agentId}" is not bound to mandate ${parsed.data.mandateId}` }); + return; + } + agentId = agent.agentId; + } + if (!agentId) { + res.status(400).json({ error: "agentId is required when not using a registered agent key" }); + return; + } + try { - const result = await runProposal(parsed.data); + const result = await runProposal({ ...parsed.data, agentId }); recent.set(result.proposal.id, result); res.status(200).json({ proposalId: result.proposal.id, @@ -118,7 +169,10 @@ export function startApiServer(): Server { app.get("/status/:id", (req: Request, res: Response) => { const result = recent.get(req.params.id as string); - if (!result) { + const agent = res.locals.agent as AgentRecord | undefined; + // A registered agent only ever sees its own proposals. Someone else's is + // reported as unknown, so its existence is not revealed either. + if (!result || (agent && result.proposal.agentId !== agent.agentId)) { res.status(404).json({ error: "Unknown proposal id (not seen since this server started)" }); return; } @@ -134,6 +188,10 @@ export function startApiServer(): Server { app.get("/escalations/:approvalId", async (req: Request, res: Response) => { try { const record = await getApproval(req.params.approvalId as string); + const agent = res.locals.agent as AgentRecord | undefined; + if (agent && record.proposal.agentId !== agent.agentId) { + throw new ApprovalNotFoundError(record.approvalId); + } res.json({ approvalId: record.approvalId, status: record.status, diff --git a/src/audit/log.ts b/src/audit/log.ts index 71022e3..e237fee 100644 --- a/src/audit/log.ts +++ b/src/audit/log.ts @@ -19,7 +19,9 @@ export type AuditEventType = | "APPROVAL_REJECTED" | "APPROVAL_EXPIRED" | "KILL_SWITCH_ENGAGED" - | "KILL_SWITCH_RELEASED"; + | "KILL_SWITCH_RELEASED" + | "AGENT_REGISTERED" + | "AGENT_REVOKED"; export interface AuditEntry { seq: number; diff --git a/src/cli/commands/agents.ts b/src/cli/commands/agents.ts new file mode 100644 index 0000000..4df706e --- /dev/null +++ b/src/cli/commands/agents.ts @@ -0,0 +1,43 @@ +import { userInfo } from "node:os"; +import { listAgents, registerAgent, revokeAgent, rotateAgent } from "../../agents/service.js"; + +function operator(explicit?: string): string { + return explicit ?? userInfo().username; +} + +function printKey(action: string, agentId: string, mandateIds: string[], key: string): void { + console.log(`${action} agent "${agentId}", bound to: ${mandateIds.join(", ")}`); + console.log(""); + console.log(` API key: ${key}`); + console.log(""); + console.log("This is the only time the key is shown. Only its hash is stored, so it cannot be recovered."); + console.log("The agent sends it as the X-Charter-Api-Key header. Its identity comes from the key, not from the request body."); +} + +export async function agentAddCommand(agentId: string, mandateIds: string[], by?: string): Promise { + const { key } = await registerAgent(agentId, mandateIds, operator(by)); + printKey("Registered", agentId, mandateIds, key); +} + +export async function agentRotateCommand(agentId: string, by?: string): Promise { + const { key, mandateIds } = await rotateAgent(agentId, operator(by)); + printKey("Rotated the key for", agentId, mandateIds, key); + console.log("The previous key stopped working immediately."); +} + +export async function agentRevokeCommand(agentId: string, by?: string): Promise { + await revokeAgent(agentId, operator(by)); + console.log(`Revoked "${agentId}". Its key no longer works.`); +} + +export async function agentListCommand(): Promise { + const agents = await listAgents(); + if (agents.length === 0) { + console.log("No agents registered."); + return; + } + for (const a of agents) { + const tail = a.status === "revoked" ? ` revoked ${a.revokedAt} by ${a.revokedBy}` : ""; + console.log(`${a.agentId} ${a.status.toUpperCase()} mandates: ${a.mandateIds.join(", ")} registered ${a.registeredAt} by ${a.registeredBy}${tail}`); + } +} diff --git a/src/config.ts b/src/config.ts index 6f7ed18..50acefe 100644 --- a/src/config.ts +++ b/src/config.ts @@ -16,6 +16,7 @@ const ConfigSchema = z.object({ apiPort: z.coerce.number().int().positive(), apiKey: z.string().optional(), approverKey: z.string().optional(), + requireAgentKeys: z.boolean(), approvalTtlMinutes: z.coerce.number().positive(), auditLogPath: z.string(), mandatesDir: z.string(), @@ -39,6 +40,7 @@ function loadConfig(): Config { apiPort: process.env.CHARTER_API_PORT ?? 4477, apiKey: process.env.CHARTER_API_KEY || undefined, approverKey: process.env.CHARTER_APPROVER_KEY || undefined, + requireAgentKeys: process.env.CHARTER_REQUIRE_AGENT_KEYS === "true", approvalTtlMinutes: process.env.CHARTER_APPROVAL_TTL_MINUTES ?? 10, auditLogPath: process.env.CHARTER_AUDIT_LOG_PATH ?? "./data/audit.log.jsonl", mandatesDir: process.env.CHARTER_MANDATES_DIR ?? "./data/mandates", diff --git a/src/index.ts b/src/index.ts index df5a4c8..71c92de 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import { auditTailCommand, auditVerifyCommand } from "./cli/commands/audit.js"; import { mandateCompileCommand } from "./cli/commands/mandate.js"; import { startApiServer } from "./api/server.js"; import { startDashboard } from "./cli/ui.js"; +import { agentAddCommand, agentListCommand, agentRevokeCommand, agentRotateCommand } from "./cli/commands/agents.js"; import { approvalsListCommand, approveCommand, @@ -128,6 +129,43 @@ program await controlStatusCommand(); }); +const agent = program.command("agent").description("Register the agents allowed to propose, each with its own key bound to specific mandates"); + +agent + .command("add") + .description("Register an agent and print its key once") + .argument("") + .requiredOption("--mandate ", "mandate id(s) this agent may propose against") + .option("--by ", "who is registering (defaults to your OS username)") + .action(async (agentId: string, opts: { mandate: string[]; by?: string }) => { + await agentAddCommand(agentId, opts.mandate, opts.by); + }); + +agent + .command("rotate") + .description("Issue a new key for an agent; the old one stops working immediately") + .argument("") + .option("--by ") + .action(async (agentId: string, opts: { by?: string }) => { + await agentRotateCommand(agentId, opts.by); + }); + +agent + .command("revoke") + .description("Revoke an agent's key") + .argument("") + .option("--by ") + .action(async (agentId: string, opts: { by?: string }) => { + await agentRevokeCommand(agentId, opts.by); + }); + +agent + .command("list") + .description("List registered agents") + .action(async () => { + await agentListCommand(); + }); + const audit = program.command("audit").description("Inspect the hash-chained audit log"); audit diff --git a/tests/agents.service.test.ts b/tests/agents.service.test.ts new file mode 100644 index 0000000..5a302ff --- /dev/null +++ b/tests/agents.service.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const store = vi.hoisted(() => ({ + entries: [] as Array<{ seq: number; timestamp: string; type: string; venue: string; payload: unknown; prevHash: string; hash: string }>, +})); + +vi.mock("../src/audit/log.js", () => ({ + auditLog: { + append: vi.fn(async (type: string, venue: string, payload: unknown) => { + const e = { seq: store.entries.length, timestamp: new Date().toISOString(), type, venue, payload, prevHash: "x", hash: "y" }; + store.entries.push(e); + return e; + }), + all: vi.fn(async () => [...store.entries]), + }, +})); + +import { + AgentExistsError, + AgentNotFoundError, + authenticateAgent, + constantTimeEquals, + registryInUse, + hashKey, + listAgents, + registerAgent, + revokeAgent, + rotateAgent, +} from "../src/agents/service.js"; + +const M1 = "00000000-0000-0000-0000-000000000001"; +const M2 = "00000000-0000-0000-0000-000000000002"; + +beforeEach(() => { + store.entries.length = 0; +}); + +describe("registerAgent", () => { + it("returns a key once and stores only its hash", async () => { + const { key } = await registerAgent("alpha", [M1], "alice"); + expect(key.startsWith("chk_")).toBe(true); + + const logged = JSON.stringify(store.entries); + expect(logged).not.toContain(key); + expect(logged).toContain(hashKey(key)); + }); + + it("issues a different key each time", async () => { + const a = await registerAgent("alpha", [M1], "alice"); + const b = await registerAgent("beta", [M1], "alice"); + expect(a.key).not.toBe(b.key); + }); + + it("refuses to register an active agent twice", async () => { + await registerAgent("alpha", [M1], "alice"); + await expect(registerAgent("alpha", [M1], "alice")).rejects.toBeInstanceOf(AgentExistsError); + }); + + it("refuses an agent bound to no mandate", async () => { + await expect(registerAgent("alpha", [], "alice")).rejects.toThrow("at least one mandate"); + }); + + it("refuses an unsafe or empty agent id", async () => { + await expect(registerAgent("", [M1], "alice")).rejects.toThrow(); + await expect(registerAgent("has space", [M1], "alice")).rejects.toThrow(); + await expect(registerAgent("../etc", [M1], "alice")).rejects.toThrow(); + }); + + it("allows registering again once the agent has been revoked", async () => { + await registerAgent("alpha", [M1], "alice"); + await revokeAgent("alpha", "alice"); + await expect(registerAgent("alpha", [M2], "alice")).resolves.toBeDefined(); + }); +}); + +describe("authenticateAgent", () => { + it("identifies the agent a key belongs to, with its mandates", async () => { + const { key } = await registerAgent("alpha", [M1, M2], "alice"); + const agent = await authenticateAgent(key); + expect(agent?.agentId).toBe("alpha"); + expect(agent?.mandateIds).toEqual([M1, M2]); + }); + + it("does not confuse two agents' keys", async () => { + const a = await registerAgent("alpha", [M1], "alice"); + const b = await registerAgent("beta", [M2], "alice"); + expect((await authenticateAgent(a.key))?.agentId).toBe("alpha"); + expect((await authenticateAgent(b.key))?.agentId).toBe("beta"); + }); + + it("rejects an unknown, empty, or missing key", async () => { + await registerAgent("alpha", [M1], "alice"); + expect(await authenticateAgent("chk_wrong")).toBeUndefined(); + expect(await authenticateAgent("")).toBeUndefined(); + expect(await authenticateAgent(undefined)).toBeUndefined(); + }); + + it("rejects a revoked agent's key", async () => { + const { key } = await registerAgent("alpha", [M1], "alice"); + await revokeAgent("alpha", "alice"); + expect(await authenticateAgent(key)).toBeUndefined(); + }); +}); + +describe("rotateAgent", () => { + it("invalidates the old key immediately and keeps the mandates", async () => { + const first = await registerAgent("alpha", [M1, M2], "alice"); + const second = await rotateAgent("alpha", "alice"); + + expect(await authenticateAgent(first.key)).toBeUndefined(); + const agent = await authenticateAgent(second.key); + expect(agent?.agentId).toBe("alpha"); + expect(agent?.mandateIds).toEqual([M1, M2]); + }); + + it("cannot rotate an agent that does not exist or is revoked", async () => { + await expect(rotateAgent("ghost", "alice")).rejects.toBeInstanceOf(AgentNotFoundError); + await registerAgent("alpha", [M1], "alice"); + await revokeAgent("alpha", "alice"); + await expect(rotateAgent("alpha", "alice")).rejects.toBeInstanceOf(AgentNotFoundError); + }); +}); + +describe("revokeAgent, listAgents, registryInUse", () => { + it("revoking an unknown agent is an error", async () => { + await expect(revokeAgent("ghost", "alice")).rejects.toBeInstanceOf(AgentNotFoundError); + }); + + it("records who registered and who revoked", async () => { + await registerAgent("alpha", [M1], "alice"); + await revokeAgent("alpha", "bob"); + const [agent] = await listAgents(); + expect(agent).toMatchObject({ agentId: "alpha", status: "revoked", registeredBy: "alice", revokedBy: "bob" }); + }); + + it("registryInUse stays true after the last agent is revoked, so the API does not reopen", async () => { + expect(await registryInUse()).toBe(false); + await registerAgent("alpha", [M1], "alice"); + expect(await registryInUse()).toBe(true); + await revokeAgent("alpha", "alice"); + expect(await registryInUse()).toBe(true); + }); +}); + +describe("constantTimeEquals", () => { + it("compares correctly", () => { + expect(constantTimeEquals("same", "same")).toBe(true); + expect(constantTimeEquals("same", "diff")).toBe(false); + expect(constantTimeEquals("short", "a much longer value")).toBe(false); + }); +}); diff --git a/tests/api.server.test.ts b/tests/api.server.test.ts index 6786b57..acfdbb2 100644 --- a/tests/api.server.test.ts +++ b/tests/api.server.test.ts @@ -5,13 +5,29 @@ import type { Server } from "node:http"; const cfg = vi.hoisted(() => ({ apiKey: "agent-key" as string | undefined, approverKey: "approver-key" as string | undefined, + requireAgentKeys: false, apiPort: 0, approvalTtlMinutes: 10, mandatesDir: "unused", })); +const auditStore = vi.hoisted(() => ({ + entries: [] as Array<{ seq: number; timestamp: string; type: string; venue: string; payload: unknown; prevHash: string; hash: string }>, +})); + vi.mock("../src/config.js", () => ({ config: cfg })); -vi.mock("../src/audit/log.js", () => ({ auditLog: { tail: vi.fn(async () => []), append: vi.fn(), all: vi.fn(async () => []), verify: vi.fn() } })); +vi.mock("../src/audit/log.js", () => ({ + auditLog: { + tail: vi.fn(async () => []), + append: vi.fn(async (type: string, venue: string, payload: unknown) => { + const e = { seq: auditStore.entries.length, timestamp: new Date().toISOString(), type, venue, payload, prevHash: "x", hash: "y" }; + auditStore.entries.push(e); + return e; + }), + all: vi.fn(async () => [...auditStore.entries]), + verify: vi.fn(), + }, +})); vi.mock("../src/venues/index.js", () => ({ activeVenue: { name: "testnet" }, marketDataBaseUrl: () => "x" })); vi.mock("../src/policy/assess.js", () => ({ assessProposal: vi.fn() })); vi.mock("../src/execution/adapter.js", () => ({ executeProposal: vi.fn() })); @@ -50,6 +66,8 @@ afterAll(() => { beforeEach(() => { cfg.apiKey = "agent-key"; cfg.approverKey = "approver-key"; + cfg.requireAgentKeys = false; + auditStore.entries.length = 0; vi.mocked(runProposal).mockReset(); }); @@ -191,3 +209,122 @@ describe("error responses", () => { expect(text).not.toContain("at "); }); }); + +describe("per-agent keys", () => { + const mandateA = "00000000-0000-0000-0000-00000000000a"; + const mandateB = "00000000-0000-0000-0000-00000000000b"; + const bodyFor = (mandateId: string, extra: Record = {}) => + JSON.stringify({ mandateId, symbol: "BTCUSDT", side: "BUY", usd: 15, ...extra }); + + const proposed = (agentId: string) => + ({ proposal: { id: "p-" + agentId, agentId }, verdict: { decision: "PASS" } }) as never; + + async function agentWithKey(agentId: string, mandateIds: string[]) { + const { registerAgent } = await import("../src/agents/service.js"); + return (await registerAgent(agentId, mandateIds, "alice")).key; + } + + it("takes the agent's identity from its key, not from the request body", async () => { + const key = await agentWithKey("alpha", [mandateA]); + vi.mocked(runProposal).mockResolvedValue(proposed("alpha")); + + const res = await fetch(`${base}/propose`, { method: "POST", headers: { ...json, "X-Charter-Api-Key": key }, body: bodyFor(mandateA) }); + expect(res.status).toBe(200); + expect(vi.mocked(runProposal).mock.calls[0]![0].agentId).toBe("alpha"); + }); + + it("refuses a body that claims to be a different agent", async () => { + const key = await agentWithKey("alpha", [mandateA]); + const res = await fetch(`${base}/propose`, { + method: "POST", + headers: { ...json, "X-Charter-Api-Key": key }, + body: bodyFor(mandateA, { agentId: "beta" }), + }); + expect(res.status).toBe(403); + expect(runProposal).not.toHaveBeenCalled(); + }); + + it("refuses a mandate the agent is not bound to", async () => { + const key = await agentWithKey("alpha", [mandateA]); + const res = await fetch(`${base}/propose`, { method: "POST", headers: { ...json, "X-Charter-Api-Key": key }, body: bodyFor(mandateB) }); + expect(res.status).toBe(403); + expect(runProposal).not.toHaveBeenCalled(); + }); + + it("rejects a revoked agent's key", async () => { + const key = await agentWithKey("alpha", [mandateA]); + const { revokeAgent } = await import("../src/agents/service.js"); + await revokeAgent("alpha", "alice"); + const res = await fetch(`${base}/propose`, { method: "POST", headers: { ...json, "X-Charter-Api-Key": key }, body: bodyFor(mandateA) }); + expect(res.status).toBe(401); + }); + + it("once agents are registered and no shared key is set, an unauthenticated caller is refused", async () => { + cfg.apiKey = undefined; + await agentWithKey("alpha", [mandateA]); + const res = await fetch(`${base}/propose`, { method: "POST", headers: json, body: bodyFor(mandateA, { agentId: "anyone" }) }); + expect(res.status).toBe(401); + }); + + it("revoking the last agent does not reopen the API to unauthenticated callers", async () => { + cfg.apiKey = undefined; + const key = await agentWithKey("alpha", [mandateA]); + const { revokeAgent } = await import("../src/agents/service.js"); + await revokeAgent("alpha", "alice"); + + const withRevokedKey = await fetch(`${base}/propose`, { method: "POST", headers: { ...json, "X-Charter-Api-Key": key }, body: bodyFor(mandateA, { agentId: "x" }) }); + const withNoKey = await fetch(`${base}/propose`, { method: "POST", headers: json, body: bodyFor(mandateA, { agentId: "x" }) }); + expect(withRevokedKey.status).toBe(401); + expect(withNoKey.status).toBe(401); + expect(runProposal).not.toHaveBeenCalled(); + }); + + it("the shared key still works alongside registered agents, unless agent keys are required", async () => { + await agentWithKey("alpha", [mandateA]); + vi.mocked(runProposal).mockResolvedValue(proposed("legacy")); + + const ok = await fetch(`${base}/propose`, { method: "POST", headers: { ...json, ...AGENT }, body: bodyFor(mandateA, { agentId: "legacy" }) }); + expect(ok.status).toBe(200); + + cfg.requireAgentKeys = true; + const refused = await fetch(`${base}/propose`, { method: "POST", headers: { ...json, ...AGENT }, body: bodyFor(mandateA, { agentId: "legacy" }) }); + expect(refused.status).toBe(401); + }); + + it("a shared-key or open caller must still say who it is", async () => { + const res = await fetch(`${base}/propose`, { method: "POST", headers: { ...json, ...AGENT }, body: bodyFor(mandateA) }); + expect(res.status).toBe(400); + }); + + it("an agent cannot read another agent's escalation, and cannot tell it exists", async () => { + const keyA = await agentWithKey("alpha", [mandateA]); + vi.mocked(service.getApproval).mockResolvedValue({ + approvalId: "ap1", + status: "pending", + expiresAt: "later", + proposal: { agentId: "beta" }, + } as never); + + const res = await fetch(`${base}/escalations/ap1`, { headers: { "X-Charter-Api-Key": keyA } }); + expect(res.status).toBe(404); + }); + + it("an agent can read its own escalation", async () => { + const keyA = await agentWithKey("alpha", [mandateA]); + vi.mocked(service.getApproval).mockResolvedValue({ + approvalId: "ap1", + status: "pending", + expiresAt: "later", + proposal: { agentId: "alpha" }, + } as never); + + const res = await fetch(`${base}/escalations/ap1`, { headers: { "X-Charter-Api-Key": keyA } }); + expect(res.status).toBe(200); + }); + + it("an agent key cannot use approver routes", async () => { + const key = await agentWithKey("alpha", [mandateA]); + const res = await fetch(`${base}/control/halt`, { method: "POST", headers: { ...json, "X-Charter-Api-Key": key }, body: "{}" }); + expect(res.status).toBe(401); + }); +});