← All docs

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.

CredentialPrefix / formHeaderGrants
Management keysk_x-mgmt-keyOwner plane: policy, agents, vault, approvals, webhooks, stats
Agent keypxy_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 tokenHS256 JWTAuthorization: BearerInject a scoped credential; cap one execution's spend
Human sessionBetter Auth (Google, GitHub, Apple) or a management-key cookiesession cookieThe dashboard, with a wallet role: owner / admin / viewer [WALLET-MEMBERS]
Slack action tokenHS256 JWT in the buttonSlack interactive payloadOne 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 as owner.

  • Claim-time key rotation. Wallet signup does not verify owner_email, so whoever created a wallet may hold its sk_ before the real owner arrives. Wallet.ownerEmailVerifiedAt records proof: null at signup (API, /start, delegated pools) and cleared whenever owner_email changes. 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_email on 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 the sk_. A link sent to a previous owner_email is 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/inject and the gateway's outbound provider header. They are never logged, and every response carrying a secret sets Cache-Control: no-store [SEC-13].
  • Reserved labels. provider:* (connected provider keys) and mcp:* (broker upstream configs) are server-side only: /exec refuses 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:

  1. Create the role and grants above on the production branch.
  2. Set DATABASE_URL to sanction_app (pooler host) for Preview only; deploy a preview and run bash scripts/smoke.sh against it (vault store/inject, clearance, Slack approvals).
  3. Check preview logs for the SECURITY: DB role has ... error — there should be none.
  4. 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 jti is 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; /authorize re-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-Key replays the original decision, never a double spend.
  • Every decision stores the policy revision and the exact context it evaluated; GET /authorize/{id}/evidence replays 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/mcp authenticates 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/call is authorized before a byte reaches the upstream, batches that smuggle a tools/call are 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.