Skip to content

fix: refuse a hook-rewritten call on a backend that would run the original - #371

Merged
saucam merged 3 commits into
mainfrom
fix/hook-rewrite-unsupported-367
Oct 11, 2026
Merged

saucam merged 3 commits into
mainfrom
fix/hook-rewrite-unsupported-367

Conversation

@saucam

@saucam saucam commented Oct 11, 2026

Copy link
Copy Markdown
Collaborator

What this fixes

A tool_call hook can rewrite a tool call before it is approved, for example to remove a dangerous part of a shell command. Only some backends actually run the rewritten version.

codex, the Gemini CLI and qwen run the input they originally proposed, whatever codeoid answers. On those, the rewrite was shown and approved, but the original was what the backend would run.

Now:

  • Backends that run the rewrite (Claude, pi, OpenAI, Gemini, llama.cpp) behave as before.
  • Backends that can't run it refuse a rewritten call before it's shown or waits for approval. The call ends cancelled, a message explains why, and the refusal is audited. To change what such a backend runs, a hook can block the call instead.
  • pi used to merge a rewrite key by key, so a rewrite that removed a field left it in what ran. It now replaces the input exactly.
  • New backends fail closed: a backend has to declare that it runs rewritten input before rewrites are allowed on it.

Verification

Ran on a real daemon (codeoid start --local), with a hook that rewrites every shell call to echo SAFE and an agent asked to rm a.txt:

  • Claude: codeoid approve showed echo SAFE. After approving, the file was still there and echo SAFE ran.
  • codex: the call was refused before anything was waiting, the file was kept, and the refusal was audited as session.hook_rewrite_refused.

Found while reproducing the bug on main: codex currently rejects every approval reply codeoid sends, because codeoid uses outdated decision words. So on main, approved codex commands fail outright rather than running the original. That's filed as #369.

This PR is still needed. Once #369 is fixed, codex really will run whatever it proposed, and without this change a hook's rewrite would be shown and then not applied.

Tests:

  • Five session tests: autonomous mode, interactive mode, a backend that does apply rewrites, form answers the backend would drop (the card must end cancelled), and a hook that returns the input unchanged.
  • Three tests that load the real generated pi bridge: a rewrite replaces the input, keys named like constructor / __proto__ are handled safely, and deny still blocks.
  • All of them fail with the fix reverted.
  • Full suite: 2982 pass, 0 fail. Typecheck and lint are clean.

Not run: no end-to-end regression tier; codeoid has none.

Review:

  • Round 1 security review: no Critical or High issues. One Low, fixed: refused form answers left the tool card at "executing" and the call audited as approved.
  • Three smaller improvements also landed:
    • A hook that returns the input unchanged no longer counts as a rewrite.
    • The check uses the backend the gate was built for.
    • pi's bridge uses own-key checks and never assigns __proto__.
  • Round 2: clean.

Follow-ups found along the way:

Closes #367

For agents: implementation detail
  • Capability. AgentProvider.appliesToolInputRewrite?: boolean; absent counts as false.
    • true, with one-line reasons in the code: ClaudeProvider, OpenAIProvider, GeminiProvider, PiProvider.
    • false, with the reason: CodexProvider (the reply carries only a decision), GeminiAcpProvider (it only selects an option), QwenProvider.
      • qwen's SDK path assigns toolCall.request.args = updatedInput, but _executeToolCallBody runs scheduledCall.invocation, which was built from the original args. Only setArgsInternal rebuilds it, and the SDK confirmation path never calls it.
    • StatelessSessionProvider forwards the inner provider's value. MockSessionProvider defaults to true and is mutable for tests.
  • Session (makeCanUseToolFn):
    • When a hook returns updatedInput and the current provider lacks the capability, the call is denied before #applyHookInputMutation. The tool message is resolved cancelled with the reason, a hook.rewrite_refused info message is sent, and the audit records session.hook_rewrite_refused.
    • An approver's form patch on such a provider is denied with session.patch_refused.
    • The capability is read per call, so a mid-session backend switch is honoured.
  • pi bridge. updatedInput replaces event.input (keys missing from it are deleted). That's safe because updatedInput is always the full input: the hook rewrite, or the approver patch merged onto it. The fake-pi fixture mirrors this.
  • Docs. docs/hooks.md and the session.approvals protocol note list which backends apply a rewrite.

🤖 Generated with Claude Code

saucam and others added 3 commits October 11, 2026 18:25
…ginal

A tool_call hook can rewrite a call's input before it is approved. Only
some backends run the rewritten input. codex and the Gemini CLI answer
an approval with yes/no and run the input they proposed, and qwen-code
assigns the new args but executes the invocation it built from the
original ones. On those, the rewrite was shown (and approved) while the
original was what codex/gemini/qwen would run.

- New provider capability `appliesToolInputRewrite` (absent = false, so
  a new backend fails closed). Declared true for claude (the SDK runs
  the permission result's updatedInput), the tool-loop backends (openai,
  gemini, llama.cpp), and pi; explicitly false, with the reason, for
  codex, the Gemini CLI, and qwen.
- Session refuses such a call before the rewrite reaches any display or
  parks: the tool call ends cancelled with the reason, an info message
  says why, and it is audited as session.hook_rewrite_refused. The same
  guard covers an approver's form answers (session.patch_refused).
- pi's bridge merged updatedInput key by key, so a rewrite that dropped
  a key left it in what ran. It now replaces the input.
- docs/hooks.md and the session.approvals protocol note say which
  backends apply a rewrite.

Closes #367

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Security review of the #367 change:

- An approver's form answers refused on a backend that can't apply them
  left the tool card at "executing" and the call audited as approved:
  the refusal ran after both. It now runs right after the approval wait,
  before the status, card, or audit move.
- A hook that returns the input unchanged is not a rewrite; it no longer
  refuses the call on codex / the Gemini CLI / qwen.
- The rewrite check uses the backend that asked, captured before any
  await, not whatever the session's provider is by then.
- pi bridge: own-key checks, and never assign `__proto__`.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
@saucam

saucam commented Oct 11, 2026

Copy link
Copy Markdown
Collaborator Author

pr-shepherd: CI is green on 2444c37 (iteration 1, no auto-fixes needed). daemon-check, web-check, Socket Security: Project Report and Socket Security: Pull Request Alerts all passed. The branch is up to date with main. Ready for human review.

@saucam

saucam commented Oct 11, 2026

Copy link
Copy Markdown
Collaborator Author

@highflame-oracle review

@saucam

saucam commented Oct 11, 2026

Copy link
Copy Markdown
Collaborator Author

@highflame-oracle review

@saucam saucam closed this Oct 11, 2026
@saucam saucam reopened this Oct 11, 2026
@saucam
saucam merged commit 4b5b801 into main Oct 11, 2026
6 checks passed
saucam added a commit that referenced this pull request Oct 11, 2026
Brings in #371 (refuse hook-rewritten calls on backends that run their
own input). One conflict, in MockSessionProvider: both sides added a
test field (appliesToolInputRewrite, ungatedToolIds) — kept both. The
session.approvals note no longer lists #368 as a known limit; it is
fixed on this branch (#374).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Hook rewrite is shown but not applied on codex and the Gemini CLI

2 participants