5.0 KiB
RFC: Narrow the pre-tool gate to shipped behavior
Status: proposed
Problem
The tools/pre-execute seam advertises two pieces of deferred capability that are not actually supported end to end: interactive ask permission and pre-tool argument rewrite.
PreToolDecision includes { kind: 'ask' }, but ToolRegistry.execute() treats every non-allow decision as a denied tool result because no permission UI exists yet (packages/core/tools/src/index.ts). The only production producer is dsh-hooks-claude, which maps Claude Code permissionDecision: "ask" into that variant; Codex has no allow/ask path. The durable hook log can still record that an external hook asked, but the canonical typed seam cannot do anything distinct with it. The public union therefore has a third branch whose runtime semantics are "deny with a different default string."
The same seam also has an unadvertised argument-rewrite escape hatch. The docs correctly say input rewrite is not offered because assistant/message, tool/call, and live presentation all see the model's original arguments before execution; changing only exec.arguments would make the UI/audit/history disagree with what ran. Yet ToolExecution.arguments is mutable, and dispatch reads exec.arguments after tools/pre-execute, so a listener can rewrite it anyway. A test shim does exactly that to thread a generated bash task id (packages/bash/tool-bash/tests/integration.spec.ts). The proposed pre-tool input rewrite RFC exists because doing this consistently is a design unit, not a hidden mutation.
Both shapes are honest feature deferrals, but the public seam currently encodes them as if they were ready. That makes bridge code, docs, generated catalogs, and tests explain behavior whose only shipped result is "deny" or "mutate at your own risk."
Proposal
Make tools/pre-execute express the behavior it can actually provide today: allow or deny a pending tool call, without argument mutation.
- Remove
{ kind: 'ask' }fromPreToolDecision. The Claude bridge should still parse and log hookaskdecisions, but map them todenyat the typed seam with an approval-not-supported reason until a real permission prompt exists. - Update docs, generated catalogs, hook bridge README tables, and tests so
tools/pre-executeis an allow/deny gate, not an allow/deny/ask gate. - Make
ToolExecution.argumentsimmutable by contract. At minimum mark itreadonlyand stop relying on a listener-mutatedexec.argumentsfor dispatch; if a defensive runtime copy/freeze is needed to make the contract true, add it at theToolRegistry.execute()boundary. - Rewrite the one test shim that mutates
exec.argumentsto use a behavior-level helper instead of the hidden rewrite path.
When permission prompts or consistent input rewrite lands, reintroduce the smallest explicit decision shape those features need. ask belongs with a real user approval loop; argument rewrite belongs with the audit/history/presentation update described by the proposed rewrite RFC.
What we give up
Claude permissionDecision: "ask" no longer has a distinct typed-decision branch inside dsh-tools. The bridge can still preserve the external fact in hook/result.decision and still deny the call conservatively. That matches current product behavior without requiring every native plugin to handle an unusable branch.
Internal tests lose a convenient mutable-object trick. That is a good loss: public tests should not depend on an unadvertised inconsistency that production docs warn against.
Acceptance criteria
PreToolDecisioncontains onlyallowanddeny.dsh-hooks-claudestill records hookaskin hook provenance, but returns adenydecision totools/pre-execute.rg "kind: 'ask'|PreToolDecision.*ask|ask.*degrades" packages docs --glob '!docs/rfc/**'finds no remaining public pre-tool ask contract outside historical RFC text.ToolExecution.argumentsis no longer a writable rewrite path, andrg "exec\\.arguments\\s*=" packages examples --glob '!docs/rfc/**' --glob '!**/lib/**'finds no mutation.- The proposed pre-tool input rewrite RFC remains the future home for a consistent rewrite design.
pnpm run test:coverage,pnpm run test:snapshot,pnpm run doc-sync, andpnpm run hygienepass after implementation.
Risks
- A native plugin author may already have experimented with
ask. The repo is unreleased, and the branch currently cannot prompt a user; collapsing it now avoids shipping a promise that cannot be honored. - Making arguments immutable may reveal more test helpers that were relying on mutation. Those helpers should move closer to the behavior they actually need instead of preserving a public inconsistency.
- Future permission and rewrite work will add back surface area. That is fine; the new surface should land with the product workflow and consistency guarantees that make it real.