08 — Security & Privacy
Authentication mechanisms
| Credential | Used by | Verified how | Lifetime |
|---|---|---|---|
| SIWE signature | Users at login | Signature recovery → wallet address | One-shot (nonce) |
| Session JWT | Terminal → agent, terminal → gateway | HMAC-SHA256 with shared JWT_SECRET_KEY |
Short-lived |
API key hk_live_… |
Programmatic clients | SHA-256 hash lookup | Until revoked |
| x402 payment | External agents | Facilitator /verify |
Per request |
| Service token | Worker → agent (X-ACP-Token), → platform (X-API-KEY) |
Constant string compare | Rotated manually |
API key handling
Keys are generated with secrets.token_urlsafe(32), returned once, and
stored only as a SHA-256 hash with a short display prefix. There is no reveal
endpoint because there is no stored plaintext. Revocation is a soft
revoked_at timestamp, which keeps historical usage rows joinable for billing
while immediately failing authentication.
Why a shared JWT secret
The agent issues SIWE sessions and the gateway must accept them so users can manage keys with the session they already have. The alternative — a second login against the gateway — is worse UX for no security gain, since both services sit in the same trust zone.
Authorization model
Two independent axes, both enforced at the gateway:
- Tier — derived from chain state (
min_tierper model). Pay-per-call requests are additionally capped, so per-call payment cannot reach models reserved for subscribers. - Quota — monthly credits per tier, checked against the usage ledger
before every request;
402on exhaustion.
Tier resolution fails closed: if the platform API is unreachable and no cached value exists, the wallet resolves to tier 0 and premium models are denied. A cached value is preferred over an outage-induced downgrade for already-known wallets, bounding the blast radius of a platform hiccup.
Privacy guarantees
This is a product feature, so it is enforced structurally rather than by policy — with one honest caveat below on where prompts currently travel.
| Guarantee | How it is enforced |
|---|---|
| Prompts are not persisted by us by default | The usage table has no prompt column — it records token counts only |
| No training on user data | We run no training pipeline on inference traffic |
| Retention is opt-in per key | Retention defaults to off; a customer must explicitly enable it for their own key to aid debugging |
| No KYC/PII collected | Identity is a wallet address; no email, no name |
Current upstream: OpenRouter, not our own cluster. Inference is routed through OpenRouter while our owned hardware (2× DGX + 3× AGX) is not yet production-ready — see the decision log. This means prompts leave our infrastructure and are subject to OpenRouter's data policy and that of whichever model provider serves the request, not only ours. The "no third-party model sees user data" guarantee does not hold during this period. It returns once inference moves onto owned hardware, which is the plan, not a promise already kept. Do not market self-hosted privacy as a present-tense fact until that migration lands.
These properties are structural rather than policy promises: storing prompt content would require a schema change, and therefore an explicit, reviewable decision. Describe them accurately — "not retained by default, retention is opt-in per key" — rather than as absolutes.
Trust boundaries and what crosses them
| Boundary | Crosses | Never crosses |
|---|---|---|
| Internet → gateway | API keys, JWTs, payment payloads, prompts (in memory only) | Cluster topology, upstream model ids |
| Gateway → cluster | Prompt payloads (transient) | User identity, wallet addresses |
| Workers → agent | Service token, job parameters | User credentials |
| Agent → MCP servers | Tool arguments | API keys of the caller |
| Anything → chain | Signed user transactions | Private keys (users sign in their wallet) |
Threat model
| Threat | Mitigation | Residual risk |
|---|---|---|
| Stolen API key | Hash-at-rest, instant revoke, per-key usage visible in /usage |
Bounded by per-wallet quota; keys are revocable immediately |
| Tier spoofing | Tier derived from chain, never from client input | Indexer lag (minutes) |
| Quota evasion via many keys | Quota is per wallet, not per key | Sybil wallets — bounded by tier cost |
| x402 replay / fake payment | Facilitator verification before service | Facilitator compromise |
| Payment settlement failure | Verification precedes service; failures are logged and reconciled | Bounded by the per-call price |
| Keeper key compromise | On-chain maxClipSize + minClipInterval + minAmountOut |
Bounded per-clip slippage |
| Owner key compromise | Ownable2Step, multisig, Pausable |
Multisig quorum compromise |
| Reentrancy / token weirdness | ReentrancyGuard, SafeERC20, checks-effects-interactions |
Fee-on-transfer tokens unsupported by design |
| Prompt injection via scraped content | Tool output is data, never instructions; critic gate before publishing | Model-level susceptibility remains |
| Agent posts something harmful | Critic node must APPROVE; human gate on publishing initially |
Reduced, not eliminated |
| Twitter account ban | Read path uses twitterapi.io with no account session (ADR-003) | Write account only |
| Secret leakage in CI | No secrets in workflows; tests use fixtures and mock facilitators | — |
Smart-contract security posture
- OpenZeppelin v5:
Ownable2Step,Pausable,ReentrancyGuard,SafeERC20. - Custom errors, checks-effects-interactions, events on every state change.
- No upgradeability, no delegatecall — the contracts cannot be changed out from under users (ADR-006).
- Solvency guard on reward streaming; slippage floor and clip bounds on buybacks; hard cap on the early-exit penalty.
- No mainnet deployment without an external audit of the exact bytecode.
Reporting a vulnerability
Please use GitHub's private vulnerability reporting on the affected repository, or the contact in the org security policy. Do not open a public issue.