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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <mandateId>
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:
Expand Down Expand Up @@ -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

Expand Down
57 changes: 57 additions & 0 deletions docs/THREAT_MODEL.md
Original file line number Diff line number Diff line change
@@ -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.
95 changes: 95 additions & 0 deletions src/agents/service.ts
Original file line number Diff line number Diff line change
@@ -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<AgentRecord[]> {
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<void> {
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<AgentRecord | undefined> {
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<boolean> {
return deriveAgents(await auditLog.all()).size > 0;
}
59 changes: 59 additions & 0 deletions src/agents/state.ts
Original file line number Diff line number Diff line change
@@ -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<string, AgentRecord> {
const agents = new Map<string, AgentRecord>();
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;
}
Loading
Loading