Skip to content

Hooks and plugins — shipping the safety contract into the harness

Status: design (researched 2026-07-12, primary sources cited inline). Nothing here is implemented; the staged plan is at the end.

Anvil’s contract is enforced in exactly one place today: the generated runtime. execute() refuses a confirmation-required mutation without confirm: true (packages/runtime/src/executor.ts:231), refuses a required-idempotency mutation without a key (executor.ts:256), pins egress to an allowlist (executor.ts:307), and only approved operations are compiled into the manifest at all (packages/generators/src/catalog.tscompiledOperations filters state === "approved"; the MCP server filters again at packages/mcp-runtime/src/server.ts:69). That enforcement is correct and stays authoritative.

But every harness the bundle is used from — Claude Code, Codex, Antigravity, ADK — now has its own interception layer: lifecycle hooks or plugin callbacks that fire before the tool call leaves the harness. Shipping a generated hook alongside the MCP server buys three things the runtime cannot:

  1. Deny before the model burns a turn. Today a missing --confirm costs a full round trip: model calls tool → runtime returns confirmation_required → model reads the envelope → model retries. A PreToolUse hook denies in-harness and injects the reason (with the exact required flags) into the same turn. Same information as AnvilError.requiredFlags, delivered pre-flight. This is the GPS at the harness layer: the steering message shapes the next prompt instead of arriving as a failed result.
  2. Human confirmation instead of model confirmation. confirm: true is an argument the model supplies. The runtime cannot distinguish “the human approved this” from “the model decided to approve it.” Harness hooks can return ask (Claude Code) — escalating to the real permission dialog — and MCP elicitation can put the question to the human mid-call (§4). That is a genuinely new enforcement tier, not redundancy.
  3. Tamper and drift detection. The hook reads the committed catalog.json, not the server’s self-report. A swapped server binary, a dev server started with includeUnapproved: true, or a stale bundle whose approvals were since revoked exposes tools the hook will still deny. Independent artifact, independent failure domain.

What hooks do not do: replace the runtime. Hooks are fail-open by nature (the user may not install the plugin, disableAllHooks exists, Codex requires per-hook trust). The safety contract holds with zero hooks installed because the executor refuses. Hooks are the outer ring; the runtime’s own PolicyHooks (packages/runtime/src/policy.ts, spec §14) are the inner ring; the harness never becomes load-bearing.

HarnessTool-call eventConfig locationBlock / modify semanticsPlugin packaging
Claude CodePreToolUse (+ ~30 others: PostToolUse, PermissionRequest, UserPromptSubmit, Stop, Elicitation, …)~/.claude/settings.json, .claude/settings.json, plugin hooks/hooks.jsonExit 2 blocks; JSON hookSpecificOutput.permissionDecision: "allow"|"deny"|"ask"|"defer" + permissionDecisionReason, updatedInput, additionalContextFull: one plugin bundles skills + agents + hooks + MCP servers (.claude-plugin/plugin.json, hooks/hooks.json, .mcp.json, skills/); ${CLAUDE_PLUGIN_ROOT} path variable
OpenAI CodexPreToolUse (+ PermissionRequest, PostToolUse, UserPromptSubmit, SessionStart, SubagentStart/Stop, PreCompact, PostCompact, Stop)~/.codex/hooks.json, [hooks] in config.toml, <repo>/.codex/hooks.json (trust-gated)Exit 2 blocks; JSON permissionDecision: "deny"|"allow" + updatedInput; PermissionRequestdecision.behaviorPlugins carry hooks/hooks.json with PLUGIN_ROOT/PLUGIN_DATA; non-managed hooks require explicit per-definition user trust
Antigravity (CLI)PreToolUse, PostToolUse, PreInvocation, PostInvocation, Stop (third-party documented — see §5).agents/hooks.json (project); global path reported inconsistentlystdout JSON {"allow_tool": false, "deny_reason": "…"}, exit 0 always (per third-party docs)None documented. The Python SDK has programmatic Decide/Inspect/Transform hooks (HookRunner, HookResult(allow=…)) — a different, code-level surface
Google ADKbefore_tool_callback (+ after_tool_callback, on_tool_error_callback, before/after_model, before/after_agent, before/after_run, on_user_message, on_event)Programmatic: Runner(…, plugins=[MyPlugin()]); registration order; plugin callbacks run before and can short-circuit agent callbacksbefore_tool_callback returning a non-None dict skips the tool and becomes its resultA Python class (BasePlugin subclass) in a module you import — no declarative manifest
MCP-native (all clients)n/a — per-tool metadata + mid-call requestsInside the server itselfannotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) are untrusted hints; elicitation/create returns accept/decline/cancel from the humanShips with the server; zero install on the client

Sources:

The load-bearing observation: Claude Code’s plugin is the composition Anvil already is. A plugin = skills + MCP server + hooks in one directory. The bundle already contains the first two (skill/, mcp/server.js); the plugin manifest and one hook script are the only missing files. Codex’s hook contract is a near-clone of Claude Code’s (same hooks.json shape, same permissionDecision/updatedInput fields, same exit-2 semantics), so one decision core serves both.

All the data a hook needs already exists in the bundle: catalog.json carries per-operation state, effect, risk, reversible, idempotency, retrySafe, confirmationRequired, and mcpTool (packages/generators/src/catalog.ts, CatalogEntry). Hooks read that artifact; they never duplicate per-operation data. The catalog stays the single source of truth and the hook is automatically correct after re-approval + regeneration.

New generated file plugin/hookcore.mjs (Node, zero dependencies, loads ../catalog.json relative to itself):

decide(toolName, toolInput) → { decision: "allow" | "deny" | "ask",
reason?, context? }

Rules, in order (each mirrors an executor refusal, cited):

  1. Tool name not in the catalog → deny (“not an operation of this bundle” — tamper/staleness guard; the matcher scopes the hook to this server’s tools so this only fires on genuinely unknown names).
  2. state !== "approved"deny with the state (mirrors the approval filter, server.ts:69 / compiledOperations).
  3. confirmationRequired && toolInput.confirm !== trueask with the AIR confirmation.reason (mirrors executor.ts:231); context steers: “irreversible ⟨action⟩ — run with dryRun: true first, then re-invoke with confirm: true.”
  4. idempotency === "required" and no idempotency_key in input → deny with the same required-flags text the runtime emits (mirrors executor.ts:256).
  5. High-risk or irreversible mutation, otherwise clean → allow but attach context (dry-run steering). Reads never attach context — zero noise on the hot path.

Explicitly not hook-enforced: egress/base-url pinning. The hook sees tool name + arguments, not the upstream URL the runtime will build; host allowlisting stays runtime-only (executor.ts:307). Same for auth binding and retry safety — request-time concerns.

Per-harness shims translate decide() into the local dialect:

  • plugin/claude/hook.mjs — stdin JSON → stdout {hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision, permissionDecisionReason, additionalContext}}, exit 0.
  • plugin/codex/hook.mjs — same shape minus ask (Codex documents deny/allow for PreToolUse; ask degrades to deny whose reason names the flags — the model re-invokes correctly, and PermissionRequest remains the human gate).
  • plugin/adk/anvil_guard_plugin.pyBasePlugin subclass; before_tool_callback returns the structured error envelope (same confirmation_required / idempotency_required / policy_denied codes as errors.compiled.json) to short-circuit the tool, None to pass through. Constructor takes the catalog path.
  • plugin/antigravity/hook.mjs{allow_tool, deny_reason}, exit 0 (behind verification; §5). No ask verb exists; degrade as with Codex.

Claude Code — the bundle root becomes an installable plugin. Installed plugins cannot reference files outside their root (paths are copied into the plugin cache), so the plugin cannot point at a sibling directory; instead the whole bundle is the plugin:

<bundle>/
.claude-plugin/plugin.json ← name "anvil-<id>", skills: ["./skill"],
hooks: "./plugin/claude/hooks.json",
mcpServers: "./plugin/claude/mcp.json"
plugin/hookcore.mjs ← shared decision core (reads ../catalog.json)
plugin/claude/hooks.json ← PreToolUse matcher scoped to this server
plugin/claude/hook.mjs ← shim
plugin/claude/mcp.json ← { "<id>": { command: "node",
args: ["${CLAUDE_PLUGIN_ROOT}/mcp/server.js"] } }
skill/ mcp/ catalog.json … ← unchanged, already generated

skill/ already has SKILL.md with a frontmatter name, which is exactly what a custom skills path requires. One subtlety from the plugins reference: a plugin-bundled server’s tools are named mcp__plugin_<plugin>_<server>__<tool>, so the generated matcher must be mcp__plugin_anvil-<id>_<id>__.* — a matcher on the bare server name never fires. Install: claude plugin install from a marketplace entry, or claude --plugin-dir <bundle> for local use. One install = skill + server + enforcement, versioned together (set version from air.service.version so updates track approvals).

Codexplugin/codex/hooks.json in the near-identical schema plus a README: copy (or merge) into <repo>/.codex/hooks.json, accept the trust prompt Codex shows for non-managed hooks, add the MCP server under mcp_servers in config.toml. No silent install path exists by design — Codex requires the user to review the exact hook definition; the README should say so rather than fight it.

ADKdropped (see S4 in §6). The interception point is exact (before_tool_callback, non-None return skips the tool), but ADK is per-language and short-circuits with a result rather than a verb, so supporting it meant a Python/TS/Go port of the rules on top of the JS core — cross-language drift not worth the value, given the runtime already enforces the contract for an ADK app. The analysis below is kept as design history.

Antigravityplugin/antigravity/hooks.json (a PreToolUse config) copied into the project’s .agents/hooks.json, plus a hook.mjs shim over the shared core and a README. Format verified against the official https://antigravity.google/docs/hooks spec (see §5). The .agent/rules/ guidance file ships alongside as complementary prompt-shaping. Unlike the scoped Claude/Codex matchers, the Antigravity matcher is * and the shim self-scopes: Antigravity fires PreToolUse for its own built-in tools, so the shim passes any non-catalog tool through as allow and only gates this bundle’s operations. The human-approval tier maps to force_ask (Antigravity’s “always prompt, ignore Always-Allow”).

New packages/generators/src/plugins.ts:

generateHarnessPlugins(air: AirDocument): Record<string, string>
".claude-plugin/plugin.json"
"plugin/hookcore.mjs" // static template + service id interpolation
"plugin/claude/{hooks.json, hook.mjs, mcp.json}"
"plugin/codex/{hooks.json, hook.mjs, README.md}"
// "plugin/adk/*" — dropped; see S4. Kept the Antigravity rules file only.
"plugin/antigravity/{hooks.json, hook.mjs}" // flag-gated

wired into generateBundle() in bundle.ts next to generateDeploy. The templates carry no per-operation data — only the service id (for tool-name prefixes and matchers) is interpolated; everything per-operation is read from catalog.json at hook runtime. Plus one addition to the generated tests/conformance.test.ts: for every operation, hookcore.decide() and the executor must agree (hook denies ⇒ executor would refuse; hook allows ⇒ executor’s gates pass on the same input). That synthesis-agreement test is what keeps the outer ring honest — without it the hook is a second implementation waiting to drift.

CheckRuntime (authoritative)Hook (advisory outer ring)
Only approved ops exposedcompiledOperations filter; server.ts:69deny (also catches tampered/stale server)
Confirmation gateexecutor.ts:231, confirmation_requiredask → human dialog (new tier: model can’t self-confirm past it)
Idempotency key requiredexecutor.ts:256, idempotency_requireddeny pre-flight with required flags
Non-idempotent never auto-retriedretry.ts / retryIsSafe— (request-time; not visible to hook)
Egress/host pinningexecutor.ts:307, policy_denied— (hook never sees the URL)
Dry-run steeringdry_run outcome exists on requestadditionalContext suggests it before the first mutation attempt
Secret redactionredactHeaders, runtime records

Annotations are already emitted. buildMcpServer sets readOnlyHint, destructiveHint, idempotentHint, openWorldHint and the anvil/* _meta block on every tool (packages/mcp-runtime/src/server.ts:78-94). Current mapping:

AIR fieldMCP annotationCurrent mappingVerdict
effect.kindreadOnlyHintkind === "read"correct
effect.reversibledestructiveHintmutation && !reversiblecorrect — the spec default for non-read-only is true, so emitting an explicit false for reversible mutations is informative
idempotency.modeidempotentHintmode !== "none"correct
openWorldHinthardcoded truewrong for Anvil. These are closed-domain calls to one pinned upstream host (ANVIL_ALLOWED_HOSTS). Spec default is true, so emitting false is the informative value. One-line fix.
effect.risk, retries, auth.principal_meta["anvil/*"]emittedfine; clients that know Anvil read it, others ignore it

Remaining native gaps, in value order:

  1. Elicitation for the confirmation gate. Today the confirmation refusal is a structured error that round-trips through the model — and the model supplies confirm: true on retry. Where the client declares the elicitation capability (Claude Code does; it even exposes Elicitation hook events), the server should instead send elicitation/create with message = the AIR confirmation.reason and requestedSchema = {confirm: boolean}; accept + confirm: true proceeds, decline/ cancel maps to the existing confirmation_required envelope. Fall back to the current error when the capability is absent. This upgrades confirmation from model-asserted to human-asserted on every capable MCP client with zero plugin install — the strongest single item in this document.
  2. outputSchema. The server registers inputSchema only. Where AIR has a response schema, emitting outputSchema gets client-side validation of structured results for free.
  3. Spec honesty: annotations are hints — the spec says clients MUST treat them as untrusted. They inform well-behaved clients; the harness hook and the runtime remain the enforcement.
  • Antigravity — now format-verified, one runtime unknown. The official spec (https://antigravity.google/docs/hooks) settled the format the earlier third-party posts contradicted: config in .agents/hooks.json ({hook-name: {PreToolUse: [{matcher, hooks:[{type,command,timeout}]}]}}); stdin carries toolCall.{name,args}; stdout is {decision: "allow"|"deny"|"ask"|"force_ask", reason?}, exit 0. We emit an enforcing PreToolUse hook against that spec. The remaining unknown is runtime, not format: the docs’ matcher tool list is Antigravity’s built-ins, and they don’t state whether PreToolUse fires for MCP-server tool calls or how toolCall.name is spelled for them. The shim is robust to it — it strips an mcp__…__ prefix and, being self-scoping, is harmless if the event never arrives — and the README flags it. Fail-open regardless: the runtime still refuses.
  • Codex hooks are new and deliberately friction-ful. Non-managed hooks require the user to review and trust the exact definition; project-local hooks load only when .codex/ is trusted; orgs can pin allow_managed_hooks_only = true, which silently drops ours. The fetched reference lists deny/allow (not ask) for PreToolUse. All fine — but it means Codex packaging is “prepared config + instructions,” not one-command install.
  • Contract churn. Claude Code’s hook schema is versioned with the CLI and visibly growing (30 events, five handler types); Codex’s is months old; Antigravity’s is contested. This is exactly why the design is one core + disposable shims: a dialect change touches one small generated file, and the conformance agreement test catches semantic drift.
  • ADK coupling. The plugin is Python against google.adk.plugins.BasePlugin whose callback signatures can move between ADK releases; pin a tested version range in the generated README. Callback semantics (non-None dict short-circuits the tool) were verified from source, not docs.
  • Hooks are fail-open. Users disable them (disableAllHooks, Codex [features].hooks = false), or simply never install the plugin. Nothing in §3 may ever be the only place a check lives. The conformance test enforces agreement, not delegation.

5b. Configurable approval tier (model-confirm vs human-approval)

Section titled “5b. Configurable approval tier (model-confirm vs human-approval)”

The hook’s confirmation behavior is not hardwired — it reads a per-operation tier from AIR. Confirmation.humanApproval (optional; absent ⇒ model-confirm) distinguishes two enforcement levels the executor alone cannot:

  • model-confirm — a confirm: true from the model clears the gate. The hook denies pre-flight until confirm: true (naming the flag so the model re-invokes in the same turn); the runtime still enforces confirm.
  • human-approval — the model cannot self-confirm. Claude/Codex hooks return ask (the human permission dialog) regardless of a model-supplied confirm; ADK degrades to a confirmation_required envelope naming the human need.

Configured two ways in the CLI journey, both tightening-only (they never remove a gate, so no loosening-conflict resolution is involved):

  • Per-operation, via the Anvil manifest: confirmation: { human_approval: true }. Flows through the one overlay channel (confirmation.human_approval is a CONTRACT_SAFETY_PREDICATE, so dropping it is a loosening that needs authority; drift reports its removal as blocking).
  • Globally, via anvil compile --human-approval none|unsafe|all — a coarse default applied after resolution to already-gated ops (unsafe = irreversible / high / financial / destructive), which an explicit per-op manifest value always overrides.

The catalog carries humanApproval per entry, so the hook, skill callout, and Antigravity rules all branch on the same value with no duplicated data.

  • S1 — MCP-native completion (packages/mcp-runtime/src/server.ts).
    • Done: openWorldHint: false — Anvil is closed-domain (one pinned upstream host behind ANVIL_ALLOWED_HOSTS), so the informative value is false, not the spec default true.
    • Deferred — needs a schema rework, not a bolt-on: elicitation-backed confirmation. AIR synthesizes confirm as a required const: true in the operation input schema (packages/air/src/jsonschema.ts:57), so the MCP SDK rejects any call without confirm: true at input validation — before a server handler could elicit. Reaching the human via elicitation/create therefore requires decoupling the confirm gate from the served input schema (present confirm as optional at the MCP layer and let elicitation or the runtime gate supply it), which touches the canonical JSON-Schema synthesis shared with the CLI and the refusal/conformance tests. Real work; tracked separately so we don’t ship unreachable code.
    • Deferred: outputSchema. The SDK enforces that a tool declaring outputSchema returns conforming structuredContent, so attaching it on the runtime hot path would convert a non-conforming-but-successful upstream response into a server error — a regression on the safety path. Only worth it behind a conformance guarantee we don’t have per-upstream.
  • S2 — Claude Code plugin emissiondone. packages/generators/src/plugins.ts (generateHarnessPlugins) wired into bundle.ts: plugin/hookcore.mjs (shared decision core, reads catalog.json), the Claude shim, .claude-plugin/plugin.json, mcp.json, the plugin-scoped PreToolUse matcher, and the hook↔executor agreement block added to the generated conformance test. An in-repo test (plugins.test.ts) writes a bundle, imports the emitted hookcore.mjs, and asserts decide() agrees with AIR (ask on confirmation, deny on missing key, deny on unknown/revoked tool, allow on a clean read). This is the reference implementation of the outer ring.
  • S3 — Codex shimdone. plugin/codex/{hooks.json, hook.mjs, README.md} share hookcore.mjs; ask degrades to deny (Codex documents deny/allow only) and the README covers the trust flow and the field-name caveat.
  • S4 — ADK plugindropped. Built and verified for Python, TypeScript, and Go (signatures confirmed against the ADK plugin docs and adk-go, each artifact compile-checked), then removed: ADK’s plugins short-circuit by returning a result, not a verb, and its callbacks are per-language, so honoring it meant a Python/TS/Go port of the decision rules on top of the one JS hookcore — four copies to keep in agreement. That cross-language drift is exactly what this design exists to prevent, and it is not worth it: the runtime executor already enforces the contract authoritatively for an ADK app, and MCP annotations (§4) make risk visible with zero install. The JS-family reuse via a code field on hookcore.decide() was reverted with it. Claude Code and Codex — which share the one JS core — remain the supported hook targets.
  • S5 — Antigravitydone (format verified against the official https://antigravity.google/docs/hooks spec). Emits plugin/antigravity/: hooks.json (a PreToolUse config for .agents/hooks.json), a hook.mjs shim over the shared hookcore.mjs, and a README; the .agent/rules/ guidance stays as complementary prompt-shaping. Two Antigravity-specific decisions:
    1. Matcher is * and the shim self-scopes. Antigravity fires PreToolUse for its own built-in tools (run_command, view_file, …) and does not document how it namespaces MCP tools, so the hookcore deny-unknown guard (correct behind Claude/Codex’s scoped matcher) must NOT apply — the shim passes any tool not in the catalog straight through as allow, and only gates this bundle’s operations. Cost: the hook runs on every tool call.
    2. Human-approval → force_ask, Antigravity’s verb for “always prompt, ignoring Always-Allow” — a precise fit the model can’t self-confirm past; model-confirm → deny (re-invoke with confirm), everything clean → allow. Verified end-to-end by a subprocess test (plugins.test.ts) that pipes a PreToolUse event to the emitted shim and asserts its decision. Residual unknown: whether Antigravity fires PreToolUse for MCP-tool calls and under what toolCall.name — the shim strips an mcp__…__ prefix and the README flags it.