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.
1. Why
Section titled “1. Why”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.ts —
compiledOperations 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:
- Deny before the model burns a turn. Today a missing
--confirmcosts a full round trip: model calls tool → runtime returnsconfirmation_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 asAnvilError.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. - Human confirmation instead of model confirmation.
confirm: trueis an argument the model supplies. The runtime cannot distinguish “the human approved this” from “the model decided to approve it.” Harness hooks can returnask(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. - 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 withincludeUnapproved: 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.
2. What each harness actually offers
Section titled “2. What each harness actually offers”| Harness | Tool-call event | Config location | Block / modify semantics | Plugin packaging |
|---|---|---|---|---|
| Claude Code | PreToolUse (+ ~30 others: PostToolUse, PermissionRequest, UserPromptSubmit, Stop, Elicitation, …) | ~/.claude/settings.json, .claude/settings.json, plugin hooks/hooks.json | Exit 2 blocks; JSON hookSpecificOutput.permissionDecision: "allow"|"deny"|"ask"|"defer" + permissionDecisionReason, updatedInput, additionalContext | Full: one plugin bundles skills + agents + hooks + MCP servers (.claude-plugin/plugin.json, hooks/hooks.json, .mcp.json, skills/); ${CLAUDE_PLUGIN_ROOT} path variable |
| OpenAI Codex | PreToolUse (+ 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; PermissionRequest → decision.behavior | Plugins 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 inconsistently | stdout 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 ADK | before_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 callbacks | before_tool_callback returning a non-None dict skips the tool and becomes its result | A Python class (BasePlugin subclass) in a module you import — no declarative manifest |
| MCP-native (all clients) | n/a — per-tool metadata + mid-call requests | Inside the server itself | annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) are untrusted hints; elicitation/create returns accept/decline/cancel from the human | Ships with the server; zero install on the client |
Sources:
- Claude Code hooks reference: https://code.claude.com/docs/en/hooks; plugins reference (hooks + MCP + skills in one plugin,
${CLAUDE_PLUGIN_ROOT}, path-traversal rule): https://code.claude.com/docs/en/plugins-reference - Codex hooks: https://developers.openai.com/codex/hooks (redirects to https://learn.chatgpt.com/docs/hooks); config: https://developers.openai.com/codex/config-reference
- Antigravity: https://antigravity.google/docs/hooks (unfetchable here — JS-rendered; see §5), SDK hooks README: https://github.com/google-antigravity/antigravity-sdk-python/blob/main/google/antigravity/hooks/README.md, CLI hooks write-ups: https://medium.com/google-cloud/a-developers-guide-to-agent-hooks-in-antigravity-cli-4c1440febd11, https://danicat.dev/posts/20260610-mastering-hooks/
- ADK plugins: https://github.com/google/adk-python/tree/main/src/google/adk/plugins (
base_plugin.py) - MCP spec 2025-06-18: tools/annotations https://modelcontextprotocol.io/specification/2025-06-18/server/tools, elicitation https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation
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.
3. The design
Section titled “3. The design”3.1 One decision core, thin shims
Section titled “3.1 One decision core, thin shims”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):
- 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). state !== "approved"→denywith the state (mirrors the approval filter,server.ts:69/compiledOperations).confirmationRequired && toolInput.confirm !== true→askwith the AIRconfirmation.reason(mirrorsexecutor.ts:231);contextsteers: “irreversible ⟨action⟩ — run withdryRun: truefirst, then re-invoke withconfirm: true.”idempotency === "required"and noidempotency_keyin input →denywith the same required-flags text the runtime emits (mirrorsexecutor.ts:256).- High-risk or irreversible mutation, otherwise clean →
allowbut attachcontext(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 minusask(Codex documentsdeny/allowfor PreToolUse;askdegrades todenywhose reason names the flags — the model re-invokes correctly, andPermissionRequestremains the human gate).plugin/adk/anvil_guard_plugin.py—BasePluginsubclass;before_tool_callbackreturns the structured error envelope (sameconfirmation_required/idempotency_required/policy_deniedcodes aserrors.compiled.json) to short-circuit the tool,Noneto pass through. Constructor takes the catalog path.plugin/antigravity/hook.mjs—{allow_tool, deny_reason}, exit 0 (behind verification; §5). Noaskverb exists; degrade as with Codex.
3.2 Packaging per harness
Section titled “3.2 Packaging per harness”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 generatedskill/ 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).
Codex — plugin/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.
ADK — dropped (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.
Antigravity — plugin/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”).
3.3 Generator changes (sketch)
Section titled “3.3 Generator changes (sketch)”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-gatedwired 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.
3.4 Runtime-enforced vs hook-enforced
Section titled “3.4 Runtime-enforced vs hook-enforced”| Check | Runtime (authoritative) | Hook (advisory outer ring) |
|---|---|---|
| Only approved ops exposed | compiledOperations filter; server.ts:69 | deny (also catches tampered/stale server) |
| Confirmation gate | executor.ts:231, confirmation_required | ask → human dialog (new tier: model can’t self-confirm past it) |
| Idempotency key required | executor.ts:256, idempotency_required | deny pre-flight with required flags |
| Non-idempotent never auto-retried | retry.ts / retryIsSafe | — (request-time; not visible to hook) |
| Egress/host pinning | executor.ts:307, policy_denied | — (hook never sees the URL) |
| Dry-run steering | dry_run outcome exists on request | additionalContext suggests it before the first mutation attempt |
| Secret redaction | redactHeaders, runtime records | — |
4. MCP-native wins
Section titled “4. MCP-native wins”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 field | MCP annotation | Current mapping | Verdict |
|---|---|---|---|
effect.kind | readOnlyHint | kind === "read" | correct |
effect.reversible | destructiveHint | mutation && !reversible | correct — the spec default for non-read-only is true, so emitting an explicit false for reversible mutations is informative |
idempotency.mode | idempotentHint | mode !== "none" | correct |
| — | openWorldHint | hardcoded true | wrong 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/*"] | emitted | fine; clients that know Anvil read it, others ignore it |
Remaining native gaps, in value order:
- Elicitation for the confirmation gate. Today the confirmation refusal
is a structured error that round-trips through the model — and the model
supplies
confirm: trueon retry. Where the client declares theelicitationcapability (Claude Code does; it even exposesElicitationhook events), the server should instead sendelicitation/createwithmessage= the AIRconfirmation.reasonandrequestedSchema={confirm: boolean};accept+confirm: trueproceeds,decline/cancelmaps to the existingconfirmation_requiredenvelope. 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. outputSchema. The server registersinputSchemaonly. Where AIR has a response schema, emittingoutputSchemagets client-side validation of structured results for free.- 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.
5. Honest constraints
Section titled “5. Honest constraints”- 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 carriestoolCall.{name,args}; stdout is{decision: "allow"|"deny"|"ask"|"force_ask", reason?}, exit 0. We emit an enforcingPreToolUsehook 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 whetherPreToolUsefires for MCP-server tool calls or howtoolCall.nameis spelled for them. The shim is robust to it — it strips anmcp__…__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 pinallow_managed_hooks_only = true, which silently drops ours. The fetched reference listsdeny/allow(notask) 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.BasePluginwhose 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: truefrom the model clears the gate. The hook denies pre-flight untilconfirm: true(naming the flag so the model re-invokes in the same turn); the runtime still enforcesconfirm. - human-approval — the model cannot self-confirm. Claude/Codex hooks return
ask(the human permission dialog) regardless of a model-suppliedconfirm; ADK degrades to aconfirmation_requiredenvelope 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_approvalis aCONTRACT_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.
6. Staged plan
Section titled “6. Staged plan”- S1 — MCP-native completion (
packages/mcp-runtime/src/server.ts).- Done:
openWorldHint: false— Anvil is closed-domain (one pinned upstream host behindANVIL_ALLOWED_HOSTS), so the informative value isfalse, not the spec defaulttrue. - Deferred — needs a schema rework, not a bolt-on: elicitation-backed
confirmation. AIR synthesizes
confirmas a requiredconst: truein the operation input schema (packages/air/src/jsonschema.ts:57), so the MCP SDK rejects any call withoutconfirm: trueat input validation — before a server handler could elicit. Reaching the human viaelicitation/createtherefore requires decoupling the confirm gate from the served input schema (presentconfirmas 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 declaringoutputSchemareturns conformingstructuredContent, 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.
- Done:
- S2 — Claude Code plugin emission — done.
packages/generators/src/plugins.ts(generateHarnessPlugins) wired intobundle.ts:plugin/hookcore.mjs(shared decision core, readscatalog.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 emittedhookcore.mjs, and assertsdecide()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 shim — done.
plugin/codex/{hooks.json, hook.mjs, README.md}sharehookcore.mjs;askdegrades todeny(Codex documents deny/allow only) and the README covers the trust flow and the field-name caveat. - S4 — ADK plugin — dropped. 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 JShookcore— 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 acodefield onhookcore.decide()was reverted with it. Claude Code and Codex — which share the one JS core — remain the supported hook targets. - S5 — Antigravity — done (format verified against the official
https://antigravity.google/docs/hooks spec). Emits
plugin/antigravity/:hooks.json(aPreToolUseconfig for.agents/hooks.json), ahook.mjsshim over the sharedhookcore.mjs, and a README; the.agent/rules/guidance stays as complementary prompt-shaping. Two Antigravity-specific decisions:- Matcher is
*and the shim self-scopes. Antigravity firesPreToolUsefor 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 asallow, and only gates this bundle’s operations. Cost: the hook runs on every tool call. - 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 withconfirm), everything clean →allow. Verified end-to-end by a subprocess test (plugins.test.ts) that pipes aPreToolUseevent to the emitted shim and asserts its decision. Residual unknown: whether Antigravity firesPreToolUsefor MCP-tool calls and under whattoolCall.name— the shim strips anmcp__…__prefix and the README flags it.
- Matcher is