Pre-tool input rewrite — a consistent design
工具执行前输入重写——一致性设计
The [interception extension-points Agent Note](../../implemented/feature/2026-06-30-interception-extension-points.md) defines `tools/pre-execute` as an allow/deny/ask gate over an execution whose identity is already protected and whose arguments are deeply frozen. Claude Code's `PreToolUse` hook also offers `updatedInput`, so a faithful bridge needs an explicit rewrite mechanism. A rewrite cannot be a mutation escape
English
Problem
The interception extension-points Agent Note defines tools/pre-execute as an allow/deny/ask gate over an execution whose identity is already protected and whose arguments are deeply frozen. Claude Code's PreToolUse hook also offers updatedInput, so a faithful bridge needs an explicit rewrite mechanism. A rewrite cannot be a mutation escape hatch on the existing execution object: it must keep the durable history, audit record, presentation, and executed value consistent.
The problem: three readers of pre-execution arguments
In the loop, a tool call's arguments are committed to the log and read by live consumers BEFORE the tool executes:
assistant/messageis appended before tool dispatch — it is the model-history sourcederiveMessages()replays, so it carries the tool-call arguments the model itself emitted.tool/callis the durable AUDIT record, appended beforectx.tools.execute().- Human-facing presentation reads
tool/call.arguments: UI renderers pass them topresentResult;dsh-tool-bashderives the card title, the rawInput, the cwd, and the terminal-vs-background treatment from them.
An execution-only rewrite would make the UI show one command while another ran and render the result against the wrong arguments. The registry prevents that failure mode today: it structured-clones and deep-freezes arguments, makes the execution identity properties non-writable, and exposes no test shim or listener path that can replace them. The rewrite design must preserve that protected-identity boundary rather than weaken it.
Proposal
A rewrite is a pre-identity consistency transaction. When a hook supplies updatedInput, the effective value must be chosen before the registry constructs its immutable ToolExecution, and it must be reflected in all three readers atomically:
- The
tool/callaudit event records the REWRITTEN arguments (with the original retained in a sidecar field for the audit trail — a hook changed the call, and both the original and the effective arguments are facts worth keeping). - The
assistant/messagein derived history must agree with what executed — options to evaluate: rewrite the assistant message's tool-call block in place (changes what the model "sees it said"), or record a separate correction the next request carries. The CC model is that the model sees the rewrite took effect. - Presentation (
presentCall/presentResult) reads the rewritten arguments, so the UI shows what actually ran.
Extending PreToolDecision at its current firing point is insufficient: both durable records already exist by then, and the execution identity is protected. The implementation must either move the relevant decision before the log commit or add a dedicated earlier rewrite decision over the pending model call. After the loop commits the effective arguments to history and audit, it constructs the ordinary immutable execution and runs the existing allow/deny/ask and tool pipeline unchanged.
Alternatives considered
Why not mutate the execution object?
Allowing a pre-execute listener to assign exec.arguments would provide only an execution rewrite, leaving model history, audit, and presentation unchanged. Keeping the identity protected makes such partial behavior unrepresentable. Until the consistency transaction exists, a CC/Codex bridge logs and warns about updatedInput rather than claiming it was honored; TODO(pre-tool-input-rewrite) at the loop dispatch site anchors the missing earlier phase.
Acceptance criteria
- A requested rewrite is resolved before
ToolExecutionidentity is created and reflected in all three readers atomically: thetool/callaudit records the rewritten arguments (the original retained in a sidecar field), derived history agrees with what executed, and presentation renders the rewritten arguments. - The effective
ToolExecution.argumentsremains deeply frozen and non-writable throughout pre-policy, guards, dispatch, post-policy, and final observation; no mutation shim is introduced. - The CC/Codex bridges honor
updatedInputinstead of logging the faithful-but-degraded warning.
Risks
- Rewriting the
assistant/messagetool-call block changes what the model "sees it said"; whether any provider rejects that on replay is the open question that must be settled empirically before the decision shape freezes. - An earlier rewrite phase changes the ordering relationship among
assistant/message,tool/call, hook audit events, and execution; the design must pin that ordering without weakening turn enclosure or call/result adjacency.
Open questions
- Does rewriting the
assistant/messagetool-call block corrupt any provider's expectation on replay, or is a separate correction safer? - Should the original arguments be preserved on the
tool/callevent (audit) and, if so, under what field? - Does the rewrite decision move before the log commit or become a dedicated earlier extension point, and how do existing pre-tool allow/deny hooks avoid running twice?
- How does this interact with a future permission
askflow (a user approving a rewritten call)?
中文
问题
拦截扩展点 Agent Note 将 tools/pre-execute 定义为一道针对执行的允许/拒绝/询问门禁,此时执行的身份标识已受保护、参数已被深度冻结。Claude Code 的 PreToolUse 钩子还提供了 updatedInput,因此忠实的桥接需要一个显式的重写机制。重写不能是对现有执行对象的可变逃逸口:它必须保持持久化历史、审计记录、展示层与实际执行值之间的一致性。
问题本质:执行前参数的三个读取方
在 agent loop(智能体循环)中,工具调用的参数在工具执行之前就已提交到日志并被实时消费方读取:
assistant/message在工具分发之前追加——它是deriveMessages()回放时的模型历史来源,因此携带模型自身输出的工具调用参数。tool/call是持久化的审计记录,在ctx.tools.execute()之前追加。- 面向人类的展示读取
tool/call.arguments:UI 渲染器将这些参数传给presentResult;dsh-tool-bash从中派生卡片标题、rawInput、cwd 以及终端/后台处理方式。
如果只做执行层面的重写,UI 会显示一条命令而实际运行的是另一条,并且结果会对着错误的参数渲染。注册表目前通过以下方式防止这种失败模式:对 arguments 做 structured-clone 并深度冻结,将执行身份属性设为不可写,且不暴露任何可替换它们的测试 shim 或监听路径。重写设计必须维护这一受保护的身份边界,而非削弱它。
提案
重写是一个「身份标识创建前的一致性事务」。当钩子提供 updatedInput 时,有效值必须在注册表构造其不可变的 ToolExecution 之前确定,并且必须原子地反映到全部三个读取方:
tool/call审计事件记录重写后的参数(原始参数保留在一个伴随字段中,作为审计线索——钩子修改了调用,原始参数与生效参数都是值得保留的事实)。- 派生历史中的
assistant/message必须与实际执行一致。待评估的选项:就地重写 assistant 消息中的工具调用块(改变模型「看到自己说了什么」),或记录一条单独的修正让下一次请求携带。Claude Code 的模型是让模型看到重写已生效。 - 展示层(
presentCall/presentResult)读取重写后的参数,使 UI 显示实际运行的内容。
在 PreToolDecision 当前的触发点上做扩展是不够的:此时两条持久化记录已经存在,执行身份已受保护。实现必须将相关决策移到日志提交之前,或者增加一个专门的、更早的重写决策点来处理待定的模型调用。agent loop 将生效参数提交到历史和审计之后,再构造普通的不可变执行对象,并照常运行现有的允许/拒绝/询问和工具流水线。
曾考虑的替代方案
为什么不直接修改执行对象?
允许 pre-execute 监听器赋值 exec.arguments 只能提供执行层面的重写,模型历史、审计和展示层不会随之改变。保持身份标识受保护使得这种局部行为不可表达。在一致性事务实现之前,CC/Codex 桥接对 updatedInput 记录日志并发出警告,而非声称已兑现;循环分发点的 TODO(pre-tool-input-rewrite) 标记了缺失的更早阶段。
验收标准
- 请求的重写在
ToolExecution身份标识创建之前解决,并原子地反映到全部三个读取方:tool/call审计记录重写后的参数(原始参数保留在伴随字段中)、派生历史与实际执行一致、展示层渲染重写后的参数。 - 生效的
ToolExecution.arguments在 pre-policy、守卫、分发、post-policy 和最终观测全程保持深度冻结且不可写;不引入任何可变 shim。 - CC/Codex 桥接兑现
updatedInput,不再记录忠实但降级的警告。
风险
- 重写
assistant/message中的工具调用块会改变模型「看到自己说了什么」;是否有提供方在回放时拒绝这种改动,是一个需要通过实验确定的开放问题,必须在决策结构定型之前解决。 - 更早的重写阶段改变了
assistant/message、tool/call、钩子审计事件与执行之间的顺序关系;设计必须固定这一顺序,同时不削弱轮次封闭性或调用/结果邻接性。
开放问题
- 重写
assistant/message中的工具调用块是否会破坏某些提供方在回放时的预期?还是单独的修正更安全? - 原始参数是否应保留在
tool/call事件(审计)上?如果是,放在什么字段? - 重写决策是移到日志提交之前,还是成为一个专门的更早扩展点?现有的 pre-tool 允许/拒绝钩子如何避免运行两次?
- 这与未来的权限
ask流程(用户批准一个被重写的调用)如何交互?