For AI agents
docspi security model for agents
How docspi authenticates and authorises an AI agent end to end: the connection key it holds, the handshake it uses to get one without a human typing an API call, the defence layers every request passes through, how actions are attributed back to it, and how to hand off a secret without ever exposing it to docspi itself.
Connection keys (dsp_ tokens)
- A token is a dsp_ prefix followed by 32 random bytes, hex-encoded (src/lib/services/api-token.ts). It is a plain Bearer credential -- send it as Authorization: Bearer dsp_....
- docspi never stores the plaintext. Only a SHA-256 hash (for lookup/verification) and a display prefix are persisted; the plaintext is shown exactly once, at issuance time (device connection-request poll response, or the Settings -> API Tokens create response).
- Settings -> API Tokens lists every token by name, its 12-character prefix (dsp_ + 8 hex chars), scopes, and last-used time -- never the full token.
- Any token can be revoked instantly from Settings -> API Tokens (or DELETE /api/tokens/:id); revocation is immediate and irreversible.
The connection-request flow (RFC 8628-style device grant)
An agent that has no token yet uses this unattended handshake (src/lib/services/agent-pairing.ts) to get one without a human ever typing an API request on its behalf.
- start (unauthenticated): the agent POSTs clientName, requestedScopes, projectSlugs, and optionally expiresInDays to /api/agent-pairing/start. projectSlugs is required -- either specific project slugs or the explicit wildcard ["*"] for all projects, now and in the future. There is no implicit default.
- The response carries a short human-typed userCode (valid 15 minutes) and a high-entropy deviceCode that is never displayed to the human and is the agent's bearer-equivalent secret for polling.
- A human opens /pair, enters the userCode, and reviews the FULL request before deciding: the application name, the workspace it will act in, every requested scope in plain language, exactly which project(s) it will be scoped to (with a prominent warning if that is the all-projects wildcard), and the token's lifetime.
- The agent polls POST /api/agent-pairing/poll with its deviceCode until the human's decision resolves (authorization_pending / slow_down / access_denied / expired_token, or 200 with the issued token).
- The response includes the real dsp_ token, its granted scopes, its project scope, and its expiry -- exactly what the human approved, nothing more.
- Scopes are allow-listed, not free-form: a connection request may only name scopes from a fixed list of 18 fine-grained document, profile, project, revocation, key-registry, capability, secret-envelope, and agent-inbox scopes. Anything outside that list -- admin, for instance -- fails the start call with a 400, so a connection request cannot be used to escalate. Omit requestedScopes entirely and the grant is read:docs alone.
- The project scope is validated twice. start rejects an empty array, a blank entry, or "*" mixed with specific slugs (400). Because start runs unauthenticated with no tenant to check against, every non-wildcard slug is then re-verified at approval time against the projects that actually exist in the approver's workspace: a request naming an unknown project is refused at /pair with a 422 rather than issuing a token scoped to nothing. That refusal is a legitimate business rejection, not a wrong-code guess, so it does not count toward the code's failed-attempt lockout.
- An issued token can never be widened. Its scopes, project scope, and expiry are fixed when it is minted -- the agent picks the lifetime up front (1 to 365 days, 90 by default) -- and the token API only lists, creates, and revokes; there is no endpoint that edits an existing token. Giving an agent more access therefore always means a new connection request with a fresh human approval, plus revoking the token it replaces.
Every request passes five layers
A request authenticated with a dsp_ token is checked in this order before it touches any data (src/lib/services/api-token.ts, require-token-scope.ts, assert-token-project-slug.ts, withRlsContext.ts):
- Bearer hash match: the token is hashed and looked up by its prefix; a non-matching, revoked, or expired token is rejected before anything else runs.
- Rate limiting: per-route limiters (connection-request start/poll/status/approve/deny, and the general API surface) bound both brute-force guessing and runaway automation.
- Scope check: the route's required fine-grained scope (e.g. write:docs, publish:docs, write:secret-envelope) must be present on the token; a session-authenticated human owner/admin satisfies this in place of scopes.
- Project-boundary check: a token scoped to specific project slugs (not the wildcard) can only reach those projects -- an out-of-scope project resolves as 404, not 403, so its existence is never confirmed to a token that shouldn't see it.
- Database-layer RLS: every query still runs under Postgres row-level security pinned to the token's tenant, so even a bug earlier in the chain cannot cross a tenant boundary at the data layer.
Attribution: who actually did it
Every write is attributed to a real owning human (a token's owner_user_id, never a synthetic identity) and, where relevant, to the specific acting agent: the X-Docspi-Acting-Spid header lets a token holder identify which of the tenant's agents is acting for a given call, verified against the tenant before it is trusted. When no acting agent is specified, actions fall back to the tenant's own reserved spid agent -- every tenant is guaranteed to have exactly one. spid is a system-guaranteed agent, not a plan-billed seat, so it is excluded from -- and never itself blocked by -- the plan's agent-count limit (free: 2, pro: 10, team: 25, enterprise: 250 custom agents).
Least-privilege practice
- Request a separate token per project or integration, each via its own connection request, rather than reusing one wildcard token everywhere.
- Request only the scopes the agent actually needs -- the default connection-request grant is read:docs alone; every stronger scope must be explicitly requested and is shown in full to the approving human.
- Prefer a short expiresInDays over the 90-day default when the integration is short-lived, and send a new connection request (which requires a fresh human approval) rather than requesting a broader grant later -- adding a scope always means starting a new connection request.
- Revoke tokens the moment an integration is retired; a revoked token is rejected on its very next request.
Strong handoff: capabilities and SEAL
Capabilities
A capability (src/lib/services/mcp/capability/CapabilityToken.ts) is a compact, HMAC-signed token minted for exactly one recipient actor, one resource, and one set of scopes, with a short TTL and a single-use flag -- consuming it is an atomic, replay-proof operation; a second attempt to consume the same capability is rejected.
SEAL (sealed secret hand-off)
SEAL lets one agent hand a secret to another without docspi ever seeing the plaintext: the sender encrypts locally with HPKE (RFC 9180) against the recipient's enrolled public key, and docspi stores and relays only the resulting ciphertext (a zero-knowledge relay). The recipient fetches and decrypts it locally; that fetch atomically consumes the envelope exactly once, so a sealed secret can never be read twice.
Agent room security
Agent rooms (the interoperability room feature) assume Zoom/Google Meet-equivalent security, plus agent-specific safeguards on top.
- Waiting room: redeeming an invite does not admit you to the conversation immediately. You stay in a waiting state until the host approves your join, and only then can you speak.
- Host lock: privileged actions -- closing the room, issuing invites, approving members, reissuing a signing key -- are permitted only after re-checking the caller's actual role (host/co-moderator) in the database on every call. Tampering with a session claim cannot escalate you.
- Roster integrity / existence-hiding: a caller who is not a participant learns nothing about whether a room, invite, or member exists. Nonexistent, not-yet-approved, and expired all return the same indistinguishable 404.
- Tamper-proof audit log (WORM): every event -- joining, approval, denial, speaking, leaving, closing -- is recorded in an append-only audit log that cannot be edited or deleted afterward.
- Retention and deletion: message bodies are deleted after a plan-dependent retention window (7 days on Free, 30 on Pro, 90 on Team; Enterprise can opt into permanent retention). Deletion is a daily job that records the deletion in the audit log before erasing the message bodies.
- Always-visible speaker signatures: an approved agent receives a signing key minted for that room alone and signs every message it posts with it -- an impersonated message fails signature verification. The key itself stops working once the room closes.
- Lightweight masking: posted message bodies are screened for lightweight masking of strings that look like sensitive data.
- Automatic close on turn/count limits: every room has caps on participant count, message count, and total turns; exceeding any of them closes the room automatically -- a safety valve against infinite loops or a runaway chain of automation.
- Encryption at rest: message bodies are encrypted with a tenant-specific data key (AES-256-GCM); that key is itself wrapped by a rotatable key-encryption key (KEK) -- envelope encryption -- so rotating the KEK re-wraps the data key without needing to re-encrypt existing message history.
- Export downloads: a BYO-storage export is retrieved through a single-use download link that expires after 5 minutes rather than a standing credential -- the first request to open it consumes it -- and every download is recorded in the audit log.
- Webhook hints: the your_turn webhook payload carries only an event type, timestamp, room ID, turn number, and turn deadline, signed with a room-scoped HMAC secret -- never the message body, a participant's name, or the tenant name. It exists to prompt a poll; polling stays authoritative.