Security
Sanction governs money, secrets, tool calls, and model access for autonomous
agents, so the security model is the product. This documents how it works as
shipped and where the edges are. Every claim below maps to enforcing code and a
regression test in TRACEABILITY.md; the IDs in brackets are
that registry's rows. Last reconciled against main: 2026-09-02.
Authentication planes
Every plane fails closed: a missing or wrong credential is a 401/403, and a missing server secret is a 503, never an open door.
| Credential | Prefix / form | Header | Grants |
|---|---|---|---|
| Management key | sk_ | x-mgmt-key | Owner plane: policy, agents, vault, approvals, webhooks, stats |
| Agent key | pxy_ | x-api-key (x-sanction-key at the gateway; Authorization: Bearer at /mcp) | Data plane: authorize, log tokens, request execution, gateway, hosted MCP, broker |
| Execution token | HS256 JWT | Authorization: Bearer | Inject a scoped credential; cap one execution's spend |
| Human session | Better Auth (Google, GitHub, Apple) or a management-key cookie | session cookie | The dashboard, with a wallet role: owner / admin / viewer [WALLET-MEMBERS] |
| Slack action token | HS256 JWT in the button | Slack interactive payload | One Approve/Deny on one approval, bound to wallet + workspace + channel [SLACK-1] |
-
Keys are shown once and stored only as SHA-256 hashes. A database leak does not expose usable keys. Rotation keeps history [SEC-6].
-
Authorization rests on the secret, never on knowledge of an id. Wallet and agent ids are non-secret.
-
Viewers cannot mutate; every dashboard mutation re-checks session, role, and that the target row belongs to the wallet. Resetting the management key is owner-only, since an
sk_session signs in asowner. -
Claim-time key rotation. Wallet signup does not verify
owner_email, so whoever created a wallet may hold itssk_before the real owner arrives.Wallet.ownerEmailVerifiedAtrecords proof: null at signup (API,/start, delegated pools) and cleared wheneverowner_emailchanges. The first proof is a claim, by either path:- a social sign-in whose provider reports the email verified (it also links the wallet to the user; a wallet provisioned for a verified user starts verified), or
- a magic link to the current
owner_emailon an unverified wallet.
A claim rotates everything minted before it in one transaction, then sweeps again after commit: agent keys are replaced and deactivated, live execution tokens, team memberships and Slack installs are revoked, and webhooks are deleted. The social claim clears the
sk_; the magic link replaces it with the one it shows. The owner re-mints agent keys from the dashboard. A magic link on an already-verified wallet is key recovery and rotates only thesk_. A link sent to a previousowner_emailis refused.
Credentials at rest [SEC-1, SEC-2]
- Envelope encryption. Each wallet has its own data-encryption key. In production it is wrapped by AWS KMS; locally and in CI it is wrapped by an environment master key so the same code path runs everywhere.
- Values are AES-256-GCM with a random 96-bit nonce, and the ciphertext is bound to its tenant and label through the GCM AAD, so a stolen blob cannot be replayed under another wallet or label.
- Wallet keys rotate (
POST /wallets/keys/rotate); rows re-wrap lazily. - Decrypted values leave the server only through
/credentials/injectand the gateway's outbound provider header. They are never logged, and every response carrying a secret setsCache-Control: no-store[SEC-13]. - Reserved labels.
provider:*(connected provider keys) andmcp:*(broker upstream configs) are server-side only:/execrefuses them before any lookup, regardless of the row's allow-list or the agent's clearance, and the vault API will not create or rename onto them [PROV-1].
Tenant isolation [SEC-3]
Vault, Slack install, and roster tables are under Postgres row-level
security. Every read or write runs inside a transaction that sets the tenant,
so a forgotten where clause returns nothing rather than another tenant's rows.
The application role must have neither SUPERUSER nor BYPASSRLS, or RLS is
bypassed; the server checks both at startup in production and logs an error.
The DB test suite runs the app as a restricted role to prove it.
Runbook: restricted app role
Neon's default owner has BYPASSRLS (inherited via neon_superuser), so the app
must not connect as it. Create a dedicated role with SQL, as the owner —
roles created in the Neon console join neon_superuser and inherit BYPASSRLS.
CREATE ROLE sanction_app LOGIN PASSWORD '<generated>' NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE;
GRANT CONNECT ON DATABASE <db> TO sanction_app;
GRANT USAGE ON SCHEMA public TO sanction_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO sanction_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO sanction_app;
-- tables/sequences created by future migrations (run as the owner):
ALTER DEFAULT PRIVILEGES FOR ROLE <owner> IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO sanction_app;
ALTER DEFAULT PRIVILEGES FOR ROLE <owner> IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO sanction_app;
Only DATABASE_URL (the pooled data-plane URL, lib/db.ts) moves to
sanction_app. DATABASE_URL_UNPOOLED / DIRECT_URL stay on the owner:
prisma.config.ts prefers them, so migrations keep DDL rights. If neither is
set, the CLI falls back to DATABASE_URL and migrations fail as sanction_app.
Rollout:
- Create the role and grants above on the production branch.
- Set
DATABASE_URLtosanction_app(pooler host) for Preview only; deploy a preview and runbash scripts/smoke.shagainst it (vault store/inject, clearance, Slack approvals). - Check preview logs for the
SECURITY: DB role has ...error — there should be none. - Repeat for Production. Rollback is reverting
DATABASE_URL.
Verify from the app's connection:
SELECT current_user, rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user;
-- expect: sanction_app | f | f
Execution tokens [SEC-5]
- HS256, signed with
SANCTION_SIGNING_SECRET; verification pins the algorithm and the audience (the wallet). - The JWT's
jtiis the execution-token row id, so DB-side revocation (POST /exec/revoke) is immediate. - Default TTL 15 minutes, maximum one hour. Injection is scope-limited to the token's labels, clearance-gated (agent clearance ≥ the credential's bar), rejects expired credentials, and writes an audit row per access with no raw value.
- Spend under a token is capped by its hard budget;
/authorizere-reads the token under the agent's lock and debits atomically.
Decision engine [SEC-4, EVID-1]
- Rules are pure over their context; the enforcement shell reads state inside a per-agent advisory lock and, for subtree caps, uses conditional atomic counter updates, so concurrent calls cannot both pass and overshoot.
Idempotency-Keyreplays the original decision, never a double spend.- Every decision stores the policy revision and the exact context it evaluated;
GET /authorize/{id}/evidencereplays the pure rules and proves the outcome reproduces. - Denials and escalations carry a stable machine code plus remediation.
Reviewing policies created from tool packs
Ordinary allowed_tools, blocked_tools, and escalate_tools entries match
exact tool names. An empty allow-list permits any tool that another rule does
not refuse. An escalated tool must also belong to a nonempty allow-list;
otherwise it is denied before escalation. Capability and conditional-rule
patterns have separate matching semantics.
Earlier versions of the MCP tool governance, Fleet channel envelope, Agency
client safe launch, and Payment agent mandate packs supplied wildcard-looking
ordinary tool entries. Those entries do not match concrete tool names. For
example, payments:* does not block payments:charge; with an empty allow-list,
that call can be allowed. Nonempty wildcard-looking allow-lists can instead
deny intended reads and prevent intended escalations.
Pack updates do not modify saved wallet policies. Owners should inspect all three tool lists for these entries, map the intended restrictions to the exact names exposed by their upstream, and preview the corrected policy before applying it. Include intended escalations in a nonempty allow-list. Verify an allowed read, an escalated write, and a denied action against a harmless test upstream. Check ancestor policies too: a child cannot loosen their restrictions. Also check enforcement mode: observe mode records would-be decisions but does not stop execution. Correct tool lists alone do not enable enforcement.
Use the normal policy update path so changes create an immutable revision. Avoid blindly reapplying a whole pack: it also replaces the pack's budget and other policy fields. Historical revisions and decision evidence remain intact.
Human approvals
Escalations become a PendingApproval; approval mints a single-use, expiring
grant the agent redeems on retry. Policy timeouts guarantee a terminal
outcome (default: deny). The actor is recorded on every resolution — a signed-in
human, or slack:<username> for a Slack click.
Slack. Add to Slack runs OAuth v2 with a state token bound to the admin's
wallet. The bot token is stored under the wallet's envelope. Interactive clicks
are verified with Slack's v0 HMAC over the raw body (five-minute skew), rate
limited, and matched to the installed workspace and channel. Anyone in the
connected channel can decide — the channel is the approver group. The
endpoint returns 503 if the signing secret is unset.
Webhooks and outbound fetches [WEBHOOK-SIG, WEBHOOK-SSRF]
- Machine deliveries are signed HMAC-SHA256 over the exact body
(
x-sanction-signature). Slack incoming-webhook URLs are the one exception; Slack's URL is its own secret. - Registered URLs must be public
https; loopback, private ranges, and metadata hosts are rejected at registration. - Delivery and MCP-broker forwarding fetch with
redirect: "manual": a validated host cannot redirect a later call onto an internal address. The broker answers an upstream redirect as a 502, never a hop.
Hosted MCP and the broker [MCP-REMOTE-1, BROKER-1]
https://getsanction.com/mcpauthenticates the agent key from headers only, fails closed with 401, and is rate limited before authentication.- The broker fronts an upstream MCP server: every
tools/callis authorized before a byte reaches the upstream, batches that smuggle atools/callare refused, and outbound headers are built from scratch, so the agent's Sanction key is never forwarded. The upstream's own credential stays vaulted and is injected server-side.
Abuse controls [RATE]
Unauthenticated wallet creation, login, and the Slack endpoints are rate limited per IP with a DB-backed fixed window that holds across serverless instances.
Audit evidence [AUDIT-EXPORT]
GET /audit/export returns a hash-chained, HMAC-signed snapshot of decisions.
POST /audit/verify recomputes the chain and signature self-contained, naming
the first broken link if any row was altered, dropped, or reordered.
Known limitations
- Single region. Execution tokens are HS256 with one signing secret; asymmetric signing is on the roadmap.
- The rate limiter is a fixed window; a couple of requests can slip at a boundary.
- A charge that escalates and is later approved is not retroactively debited from a short-lived execution token.
- Interception holds for traffic through the gateway and the broker. The plain wallet URL and stdio MCP server are cooperative: the host must ask.
- Slack approver authority is channel membership, not a Sanction role.
Reporting a vulnerability
Email eric@getsanction.com. Please do not open a public issue for security reports. We acknowledge, fix, and appreciate coordinated disclosure.