Skip to content

Function hooks can answer a tool call, so Read/Write can be served by supertool instead of refused #2293

Description

@fdaviddpt

Claude Code 2.1.260 ships function hooks behind CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 (flag tengu_plugin_hooks_modules, GrowthBook-gated, env var overrides). A plugin declares one hooks module in hooks/hooks.json; the module hooks events and calls affordances on a $ object.

The part that matters here is not that hooks can now be written as functions. It is that a hooks module can resolve a tool call with a result, not only allow, ask or deny. From the 2.1.260 darwin-arm64 binary:

tool.call ${p.name} ${D}: resolved by a hooks module (
  + (e.deny === undefined ? "result" : `deny: ${e.deny}`) + ")"

deny is one branch. result is the other, and on that branch the tool never runs.

This closes the gap the shell-hook design could not. A PreToolUse command hook gets permissionDecision, permissionDecisionReason, updatedInput, additionalContext, systemMessage — it can rewrite arguments inside the same tool's schema, and it can refuse, but it cannot answer. So the only way to route an agent from Read to read:PATH was to deny and hope the retry picks supertool: a wasted turn, a refusal in the transcript, and an agent that may route around the guard instead (#2007 measured a subagent objecting to the redirection itself).

A hooks module removes the turn. Hook tool.call, and for Read, Grep, Glob and the write-class tools, run the equivalent op and hand back its output as the tool result. The model asked for Read, got supertool's answer with its ↳ to modify: footer, and never learned there was a substitution. Nothing is denied, so nothing is retried.

That is the premise of this repo applied one layer lower than it has ever reached: a round-trip saved is a whole model turn, and this saves the refusal turn and the re-issue turn on every mis-routed call.

Open questions this issue does not settle:

  • Which tools are worth intercepting. Read to read:PATH is the obvious one. The write class is riskier: a served result that claims a write landed when it did not is exactly the defect class in CLAUDE.md ("an unverified success and a verified one must never render alike"), and a hooks module is a new place for it to hide.
  • What a served result must disclose. A substitution the model cannot see is the point, but a human reading the transcript should still be able to tell that supertool answered rather than the tool. presets/_untrusted.py draws one such boundary already; this needs its own.
  • How it degrades. The flag is a rollout gate, so the module may not load at all. The existing PreToolUse guard has to stay correct on its own, and the two must not both fire.
  • The $ surface. Observed in the binary: $.tool.call, $.tool.register, $.tool.list, plus a generated claude-code.d.ts to type the module against. Not read yet.

Related: #1253 (PostToolUse can rewrite tool results — the same idea from the other side, and weaker: it needs the tool to have already run).

Verified 2026-09-04 against @anthropic-ai/claude-code-darwin-arm64@2.1.260 and @2.1.261, unpacked from npm. 2.1.258 does not carry the flag: 0 hits. 2.1.260 and 2.1.261: 7 each. The docs at code.claude.com/docs/en/hooks still list five hook types (command, http, mcp_tool, prompt, agent) and none of them is this, so the binary is the only source right now.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

cohort-29Open at the v0.60.0 tag, 2026-09-11. Frozen: nothing joins a cohort.cohort-30Open at the v0.61.0 tag, 2026-09-12. Frozen: nothing joins a cohort.cohort-31Open at the v0.63.0 tag, 2026-09-19. Frozen: nothing joins a cohort.cohort-32Open at the v0.64.0 tag, 2026-09-23. Frozen: nothing joins a cohort.enhancementNew feature or requestlane-containmentpayload gate and trust model — reaches into other lanes, wants their reviewpriority-mediumRecurring cost; compounds if unfixed

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions