Security
Absence over denial, the confirmation gate, and approval stores.
Absence over denial
Section titled “Absence over denial”An operation a caller cannot use is absent, not denied.
MCP tools/list and AI SDK tool records are filtered with visibleTo(principal, impl). Calling a tool by name that is not visible returns “unknown tool” — never a permission-denied payload that teaches the model what exists behind the wall.
Scopes live in ~x/scopes. Listing and enforcement both read that meta. No scopes declared → not subject to scope filtering (still needs ~x/agent to appear on agent surfaces).
Confirmation gate
Section titled “Confirmation gate”Destructive operations declare agent({ confirm: true }). Middleware confirmGate({ store }) blocks until a human approves that exact call.
End-to-end:
- Model (or client) calls the operation.
- Gate creates a pending approval and returns 428
CONFIRMATION_REQUIRED— without the approval id. - Application lists pending rows with
store.pending({ requestedBy })— operator-side only; never hand this to a model. - Human approves via
store.approve(id, approvedBy). - Application redeems by setting
context.approvalId(header / ALS / session) and retries the same operation + input. - Gate consumes the approval and the handler runs once.
Approvals bind to operation, input, and requesting caller. A different input or a different principal cannot redeem the row.
import { APPROVAL_ID_KEY, agent, confirmGate, devMemoryApprovalStore, http,} from "@prism/agent";import { execute, implement, op } from "@prism/core";import { z } from "zod";
const store = devMemoryApprovalStore();
const archive = implement( op() .input(z.object({ cardId: z.string() })) .output(z.object({ archived: z.boolean() })) .meta( http({ method: "POST", path: "/cards/{cardId}/archive" }), agent({ description: "Archive a card. Requires confirmation.", confirm: true, }), ),) .use(confirmGate({ store })) .handler(async () => ({ archived: true }));
// First call — creates pending approval, returns 428const blocked = await execute( archive, { cardId: "c1" }, { path: ["cards", "archive"], actor: { id: "user_1" } },);if (!blocked.ok) { console.log(blocked.error.code); // CONFIRMATION_REQUIRED}
// Operator channel — never expose ids to the modelconst pending = await store.pending({ requestedBy: "user_1" });const row = pending[0];if (row === undefined) { throw new Error("expected a pending approval");}await store.approve(row.id, "user_1");
// Redeem — id arrives through application context, not tool inputconst done = await execute( archive, { cardId: "c1" }, { path: ["cards", "archive"], actor: { id: "user_1" }, [APPROVAL_ID_KEY]: row.id, },);if (done.ok) { console.log(done.value); // { archived: true }}The approval id must never appear in a model-facing tool result. If the model could set approvalId, it could approve its own irreversible operation — the exact defect the gate exists to close.
Approval stores
Section titled “Approval stores”| Store | Use when |
|---|---|
devMemoryApprovalStore() |
Single-process local/dev. Lost on restart. Not for multi-instance. |
sqlApprovalStore(db) |
Multi-instance production. Shared agent_approvals table. |
import { AGENT_APPROVALS_DDL, sqlApprovalStore, type ApprovalSqlClient,} from "@prism/agent";
declare const db: ApprovalSqlClient;
await db.query(AGENT_APPROVALS_DDL);const store = sqlApprovalStore(db);
void store.pending({ requestedBy: "user_1" });memoryApprovalStore has been deleted — there is no alias. Use devMemoryApprovalStore.