Skip to content
Prism

Security

Absence over denial, the confirmation gate, and approval stores.

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).

Destructive operations declare agent({ confirm: true }). Middleware confirmGate({ store }) blocks until a human approves that exact call.

End-to-end:

  1. Model (or client) calls the operation.
  2. Gate creates a pending approval and returns 428 CONFIRMATION_REQUIREDwithout the approval id.
  3. Application lists pending rows with store.pending({ requestedBy })operator-side only; never hand this to a model.
  4. Human approves via store.approve(id, approvedBy).
  5. Application redeems by setting context.approvalId (header / ALS / session) and retries the same operation + input.
  6. 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 428
const 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 model
const 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 input
const 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.

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.