Approve what matters. Stop approving what doesn't.
A deployment running all-side-effecting asks the human before every write, which is correct and quickly becomes a reflex: the operator approves the same safe draft-save forty times a day and stops reading the prompt. The fix is not a longer allowlist. It is to let a tool declare what it does, turn that into one computed danger level, and let the deployment pick a mode. Composition narrows only, so no mode can ever widen past a rule or a host decision, and a deployment that sets nothing behaves exactly as it does today.
The risk vector
ToolRisk is six tri-state axes. The first one is the mutating field that already shipped; the other five qualify it. Every axis follows the rule mutating already used: undefined means unknown, and unknown is treated as unsafe. So an unannotated codebase is safe by construction and annotating is a pure improvement, never a new obligation.
| Axis | Question it answers | Relevant when |
|---|---|---|
| mutating | does it have a side effect at all? | always, it is the root |
| reversible | can an operator undo it? | mutating: true |
| destructive | is data irrecoverably lost? | mutating: true |
| egress | does data leave the deployment? | always, reads included |
| scope | blast radius: single / collection / deployment | mutating: true |
| live | do end users see it now, or is it a draft? | mutating: true |
// The vector is FLAT on the spec, beside the `mutating` field you already have.
// Every axis is tri-state. `undefined` means UNKNOWN, and unknown is treated as UNSAFE.
export const draftPost = defineTool({
name: "draft_post",
description: "Save a draft. Never publishes.",
inputSchema: z.object({ id: z.string(), body: z.string() }),
mutating: true, // it writes
reversible: true, // drafts are versioned; an operator can restore the previous one
destructive: false, // nothing is lost
egress: false, // the write stays inside the deployment
scope: "single", // one record
live: false, // a draft, not what readers see
execute: async (input, ctx) => { /* ... */ },
});
toolDanger(draftPost.risk); // 1 (low) -> auto-allowed at auto's default thresholdThe danger matrix
toolDanger(risk) is pure, total, and the only thing a mode ever compares against a threshold. It is evaluated top-down; an exhaustive test over the whole tri-state space asserts every combination lands on exactly one row.
| Level | Condition |
|---|---|
| 3 critical | mutating is undefined, or it is true with a gap: destructive !== false, or any of reversible / egress / scope / live left undeclared |
| 2 high | a fully-declared, non-destructive mutation that is nonetheless imperfect (irreversible, or egress, or wider than one record, or live), or mutating: false with egress undeclared |
| 1 low | a fully-declared mutation that is reversible, non-destructive, no egress, scope: "single" and not live. Or a declared-egress read (mutating: false, egress: true), e.g. a web search |
| 0 safe | mutating: false and egress: false |
The relevance rule. reversible, destructive, scope and live qualify a mutation, so they are not consulted at all for a read. For mutating: false the only relevant further axis is egress, because a read can still exfiltrate its arguments. That is why a bare { mutating: false } is danger 2, not 0: one unknown relevant axis is enough to ask. Declaring egress is what buys a read its 0.
Three modes and one threshold
SwarmConfig.approval is opt-in and layers over the toolApproval floor, never instead of it.
| Mode | What it does |
|---|---|
| manual | the legacy decision, verbatim, for both legacy policies, plus protectedTools. This is the behaviour-preserving mode. |
| auto | auto-allow every tool at or below the effective threshold; everything above it asks. |
| permissive | auto, plus it may downgrade an MCP origin-default gate (never an author's explicit needsApproval: true) within the same single threshold. |
defineSwarm({
// ...
toolApproval: { mode: "all-side-effecting" }, // the floor. Unchanged, non-removable.
approval: {
mode: "auto",
autoMaxDanger: 1, // default 1
floor: 3, // danger >= floor is NEVER auto-allowed. default 3
protectedTools: ["publish_post", "delete_page"],
},
});
// One derived number decides every auto-allow in the whole system:
effectiveThreshold({ mode: "auto" }); // 1 (min(1, 3 - 1))
effectiveThreshold({ mode: "auto", autoMaxDanger: 2 }); // 2
effectiveThreshold({ mode: "auto", autoMaxDanger: 2, floor: 2 }); // 1 (the floor binds auto too)
effectiveThreshold({ mode: "permissive", floor: 0 }); // -1 (nothing is ever auto-allowed)There is one threshold in the whole system: effectiveThreshold(cfg) = min(autoMaxDanger ?? 1, (floor ?? 3) - 1). Every allow-capable comparison is danger <= effectiveThreshold(cfg), so floor binds auto too, not only permissive. Note the direction: lowering the floor narrows. floor: 0 returns -1, which no danger level satisfies, so the mode is fully inert: legal, and useful as a kill switch.
One deliberate tightening to know about before you flip to auto: it is stricter than legacy { mode: "flag" } for unannotated tools. flag allows everything without the flag; auto asks on a danger-3 tool, and a tool that declares nothing is danger 3. Annotate first, then flip.
Four layers, and only the bottom one can widen
A mode is not a new enforcement path. It is a better seed for the fold that already existed.
| Layer | What | Can it widen? |
|---|---|---|
| 0 | resolveToolBaseline (the mode) | yes, the only stage that can |
| 1 | your preToolCall hook | no |
| 2 | code enforce tool rules | no |
| 3 | dynamic published / skill-borne rules | no |
Layers 1–3 fold with mostRestrictiveTool and foldDynamicToolRules, unchanged, and RuleAction.do has no allow at all. So a mode can never punch through a rule or through your hook, whatever it is configured to do.
The failure that costs you a day is the silent one: one broad when: { tool: "*" }, do: "ask" enforce rule makes auto completely inert, and nothing tells you. So every tightening transition now names itself, in a structured field and inside the reason string:
// A single broad enforce rule can make a mode completely inert. The decision says so.
{
action: "ask",
reason: "publishing needs a human [overridden by: prebuilt-builder-approval-floor]",
overriddenBy: "prebuilt-builder-approval-floor",
}Every transition stamps, not just the first: on an allow → ask → deny chain the final decision's overriddenBy names the most restrictive contributor while the reason preserves the whole chain. The fold functions themselves are untouched; the two composition boundaries wrap their result.
Annotating your tools
If you already gate tools by hand, you have probably written something close to this already, and the shape of it is the right instinct:
// The host pattern (a real adopter's, before this shipped): one pure resolver over your own
// tool table, returning THREE states rather than two.
function resolveMutating(name: string): boolean | undefined {
if (APPROVAL_TOOLS.has(name)) return true; // it needs approval, so it certainly mutates
if (READ_TOOLS.has(name)) return false; // an explicitly listed read
return undefined; // unknown, and unknown stays unknown
}Two things in that snippet are exactly right, and the framework agrees with both.
The three-way return. Not boolean: boolean | undefined. An unlisted tool is unknown, and saying so is more useful than guessing, because unknown resolves to unsafe on its own. That is the tri-state, one axis at a time.
Refusing to infer read-only from needsApproval. Draft-safe writes are approval-exempt and still mutating, so needsApproval === false does not mean read-only. The two are independent here too: needsApproval is a gate, mutating describes behaviour, and nothing derives one from the other. An author's explicit needsApproval: true is never downgradable by any mode, at any danger level.
What the vector adds is the other five axes and a place to put them. Instead of a resolver keyed by name in your host, the answer lives on the tool that knows it, travels with it into the catalog and the approval card, and reduces to one number the gate can compare. Migrating is mechanical: your READ_TOOLS set becomes mutating: false, egress: false on each of those tools (danger 0), and your APPROVAL_TOOLS set keeps its explicit needsApproval: true, which no mode touches. The interesting work is the tools in neither set: the draft-safe writes, which is where a level 1 buys back a dozen approvals a day.
Annotate truthfully, and leave undeclared what you genuinely cannot know. An axis you guess is worse than an axis you omit, because omitting it costs one approval prompt while guessing it wrong auto-allows the thing you were guessing about. The first-party tools follow their own rule here: search_knowledge and graph_search read your own store, but they embed the caller's query first, and only you know whether that embedder is local or hosted. So both ship with egress undeclared (danger 2, it asks) and expose it as an option: false for a local embedder buys danger 0, true for a hosted one is honest and still auto-allowed at the default threshold. The gate believes an annotation; it has to be true.
Recipe 1: make your hook deny-only
Most host preToolCall hooks exist for one narrow concern (a rate limit, an org policy check, a "needs you" queue) and then carry a second job they never wanted: re-applying the approval floor, because setting a hook made the dispatcher skip its own policy.
// BEFORE. The dispatcher returns a configured hook's decision VERBATIM, so this hook had to
// re-run the policy itself. Forget the last line and "all-side-effecting" is silently deleted
// for every tool in the deployment.
preToolCall: async (ev) => {
if (!ev.tenantId) return toolPolicyDecision(ev, TOOL_APPROVAL);
const rl = await abuse.hit("tool:" + ev.tenantId, TOOL_RATE, nowSec());
if (!rl.allow) return deny("rate limit: retry in " + rl.resetSec + "s");
return toolPolicyDecision(ev, TOOL_APPROVAL); // mandatory, and easy to get wrong
},Setting approval widens the engine's wrap condition, so your hook is folded above the resolved baseline instead of replacing it. Delete the re-application:
// AFTER. Deny-only. Because `approval` is set, the engine folds this hook ABOVE the resolved
// baseline (mostRestrictiveTool), so `allow` here means "the rate limiter has no objection".
// The baseline's `ask` still stands. Re-applying the floor is now not just unnecessary, it is
// wrong: it hides which layer actually decided in the `overriddenBy` diagnostic.
preToolCall: async (ev) => {
if (!ev.tenantId) return ALLOW;
const rl = await abuse.hit("tool:" + ev.tenantId, TOOL_RATE, nowSec());
return rl.allow ? ALLOW : deny("rate limit: too many tool calls, retry in " + rl.resetSec + "s");
},
// ...and, in the same defineSwarm call:
approval: { mode: "manual", protectedTools: ["schedule_post", "send_reply"] },This is a deliberate, opt-in tightening and it is the point of the migration: before, a hook returning allow bypassed the floor; after, it cannot. Note that protectedTools is honoured in manual too, so listing the tools that must always ask is worth doing on day one. It is a no-op under all-side-effecting, and it is the declaration that survives the day someone flips the mode to auto.
Opting in also routes the built-in tools (scratchpad_write, recall_lane, get_page_context) through the same gate, which they previously bypassed entirely. One named exemption: ask is never gate-wrapped. It is the human-approval channel, and gating it would stack a second suspension on top of the question. floor: 0 and protectedTools: ["ask"] simply do not apply to it.
Recipe 2: the arg-dependent case, per call
Some calls are safe because of how they were called, not because of what the tool is: the same publish tool passed dryRun: true is a rehearsal. The engine cannot know a tool's preview semantics, so that judgment stays host-side, but it belongs in hooks.resolveApprovalMode, not in gate logic.
// The arg-dependent case. Note the polarity, because it is the whole lesson.
//
// The instinct is "demote dryRun === true to allow". That is a WIDENING, and no per-call resolver
// can widen: layer 0 computes the deployment decision AND this one and folds them
// most-restrictive-wins. The same intent is expressed by inverting it: the permission lives in
// the DEPLOYMENT config, and the resolver takes it away from the calls that are not rehearsals.
function dryRunArg(args: unknown): boolean | undefined {
if (typeof args !== "object" || args === null) return undefined;
const v = (args as Record<string, unknown>).dryRun;
return typeof v === "boolean" ? v : undefined; // a string "true" is not a rehearsal
}
export function resolveApprovalMode(ev: ToolPreCallEvent): ApprovalModeConfig {
// no boolean dryRun in the args -> this tool has no rehearsal mode -> contribute nothing.
// dryRun: true -> a rehearsal -> contribute nothing; it keeps what the deployment granted.
if (dryRunArg(ev.args) !== false) return APPROVAL;
// dryRun: false -> the LIVE call -> narrow. protectedTools is the one knob that binds EVERY
// mode, manual included, so the live call can never be auto-allowed by any mode adopted later.
return { ...APPROVAL, protectedTools: [...PROTECTED_TOOLS, ev.toolName] };
}
// wired beside preToolCall:
hooks: defineHook({ preToolCall, resolveApprovalMode })The resolver runs only on the async gate path. Layer 0 computes the deployment decision and the per-call decision and folds them with mostRestrictiveTool, so narrow-only holds for any config the resolver returns, including the one a config-ranking design gets wrong: a per-call drop to manual under legacy flag would allow an unflagged danger-3 tool that auto asks on. Numeric sanity clamps run too (autoMaxDanger and floor are clamped down to the deployment's, protectedTools is a union), but the fold is the guarantee. A resolver that throws contributes nothing and the deployment decision stands.
One honest consequence: swarm.tool_call.needsApproval reflects the deployment-resolved baseline, because the badge is computed on a sync path that never awaits your resolver. A per-call narrowing can therefore tighten a call the badge showed as ungated. That is documented rather than hidden; if it matters to your UI, keep the arg-dependent judgment coarse enough to state at the deployment.
Third-party hints escalate only
An MCP server can attach annotations to its tools (readOnlyHint, destructiveHint, idempotentHint). We read them, and we clamp them at the materialization site, before anything can compute a danger from them.
| Hint | Candidate vector | Outcome |
|---|---|---|
| readOnlyHint: true | { mutating: false } → danger 2 | rejected: below the unannotated floor of 3, so the tool keeps danger 3 |
| destructiveHint: true | { mutating: true, destructive: true } → danger 3 | adopted, and it raises any lower declared vector |
| both | the unsafe reading | a contradiction is resolved against the server, not for it |
The clamp never touches needsApproval: an MCP tool keeps its origin: "mcp" approval default, so the gate still sees needsApprovalSource: "origin-default" is still downgradable by permissive alone, and only within its own threshold, with the danger the hint did not change. In short: a remote server can make itself look more dangerous, never less.
Byte-identical when you set nothing
Omit approval and nothing about your deployment changes. Layer 0 is toolPolicyDecision(ev, policy) verbatim, the closure stays synchronous so no extra async hop is introduced, the wrap condition stays exactly what it was so no host is newly routed through the composed path, and the built-ins stay ungated. The shipped hook and built-in test suites pass unmodified, which is the actual guarantee. Modifying one of them to make a mode work would have been a design failure, not a test update.
Set it and you take two deliberate tightenings, both described above: your hook folds above the baseline rather than replacing it, and built-ins run through the gate. Nothing loosens on its own. Reaching for { mode: "manual" } first is a real migration step, not a placeholder: it fixes the composition without moving a single decision.
API reference
- ToolRisk, the six tri-state axes. Declared FLAT on ToolSpec and ClientToolSpec, collected onto the materialized tool as SwarmTool.risk.
- toolDanger(risk: ToolRisk): DangerLevel, pure, total, never throws. toolDanger({}) === 3.
- effectiveThreshold(cfg: ApprovalModeConfig): number, the one derived number. Returns -1 for floor: 0.
- SwarmConfig.approval?: ApprovalModeConfig, the deployment mode. Omit for byte-identical legacy behaviour.
- SwarmHooks.resolveApprovalMode?(ev): ApprovalModeConfig | Promise<...>, the per-call narrowing seam. Gate path only; a throw contributes nothing.
- ToolPreCallEvent.danger / .needsApprovalSource / .userId / .threadId, additive provenance stamped at event construction. needsApprovalSource: "explicit" is never downgradable; "origin-default" only by permissive; an absent source fails closed.
- ToolDecision.overriddenBy?: string, the silent-defeat diagnostic, mirrored into reason as [overridden by: <name>].
- EngineCapabilities.governance.approvalModes?: boolean, optional; absent means unsupported. The three native engines set it. Adapter engines report rather than half-apply.
- swarm.question carries toolName + danger, and <AskBox> renders the tool name with a danger badge when they are present.
Client actions. A client tool's server gate is deny-only by design (the ask is client-mediated via confirmClientAction), so the resolved needsApproval and danger ride the swarm.client_action payload instead, on the Mastra engine. The alt engines' client-tool path bypasses the gate today; that parity gap is tracked, not quietly implied.
Where to go next
@nightowlsdev/hooks
ToolRisk, toolDanger, effectiveThreshold, resolveToolBaseline. Zero runtime dependencies.
@nightowlsdev/core
SwarmConfig.approval, the risk fields on ToolSpec, and the composition that folds above it.
Client tools
Where the browser-side confirmation seam lives, and why its gate is deny-only.
Building on Night Owls? See the source on GitHub.