Skip to content

fix(events): tool/before pass-through projects no decision instead of allow (#461) - #481

Merged
ScriptedAlchemy merged 3 commits into
mainfrom
fix/tool-before-passthrough-permission
Sep 4, 2026
Merged

fix(events): tool/before pass-through projects no decision instead of allow (#461)#481
ScriptedAlchemy merged 3 commits into
mainfrom
fix/tool-before-passthrough-permission

Conversation

@ScriptedAlchemy

@ScriptedAlchemy ScriptedAlchemy commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Fixes #461.

Severity

High / security-relevant. Any plugin with a tool/before event route (or a config-declared beforeTool hook) matching Bash turned every matching tool call in the session into an explicitly approved one on Claude Code and Codex — rm -rf, git push --force, anything — because a pass-through result was delivered as permissionDecision: "allow", which per the Claude hooks contract skips the permission prompt. A plugin could not opt out. examples/host-test (which returns an empty <Agent.Result /> for every family "so no host decision channel is touched") was itself auto-approving all tool calls on both hosts.

Root cause

  • packages/agent-bundle/src/events/projection.ts:758 (pre-fix) — permissionDecision: parsedValue?.outcome === 'deny' ? 'deny' : 'allow'. Every non-deny result, including outcome: 'continue', no value, and context-only results, became allow. Because hookEventName was always written, the output object was never empty, so the wrapper always emitted a hookSpecificOutput with permissionDecision: "allow".
  • packages/agent-bundle/src/adapters/hook-contract.ts:500 (encodeNativeHookPlaygroundOutput) and :1248 (the emitted legacy beforeTool wrapper's encodeOutput) — the same ternary for config-declared hooks.
  • projection.ts:655 — the same class of defect on permission/request: outcome: 'continue' projected decision.behavior: 'allow', answering the prompt on the user's behalf.
  • website/docs/{en,zh}/guide/authoring/hooks.mdx documented the defect as the rule ("allow unless the route denied, even when the route renders no decision").

The result vocabulary was continue | deny, so "explicit allow" and "no opinion" were indistinguishable.

Fix

  • Pass-through (continue, no value, or Agent.Context only) projects no decision on every host. With no context and no rewrite the wrapper writes nothing and exits 0, so the host proceeds exactly as it would without the hook.
  • Result vocabulary gains explicit outcome: 'allow' and outcome: 'ask' (backward compatible; pass-through stays the default). allow/ask are tool/before decisions; allow also answers permission/request. Every other family rejects them with a TypeError instead of silently treating them as continue.
  • reason on tool/before now requires a decision (no channel otherwise) — same rule the other families already enforce.
  • updatedInput is independent of the decision. On Claude/Codex a rewrite with continue carries no permissionDecision; per the Claude docs the host "evaluates permission rules … against the input your hook returns". Pair with allow to auto-approve, ask to show the rewritten input.
  • Cursor: unchanged for deny and for rewrite-without-decision (permission: 'allow', updated_input, because Cursor documents updated_input only alongside a permission); explicit allow{ permission: 'allow' }; ask fails closed because https://cursor.com/docs/hooks#pretooluse says it is "accepted by the schema but not enforced for preToolUse today".
  • Legacy config-declared beforeTool hooks: continue → no permissionDecision; deny unchanged. Their vocabulary is unchanged (continue | deny | stop); explicit allow/ask is an event-route feature. Documented.

Per-host before / after (route result → host stdout)

Result Host Before After
<Agent.Result /> or { outcome: 'continue' } Claude, Codex {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}} (no output, exit 0)
same Cursor (no output) (no output) — unchanged
<Agent.Context>ctx</Agent.Context> only Claude, Codex {"hookSpecificOutput":{"additionalContext":"ctx","hookEventName":"PreToolUse","permissionDecision":"allow"}} {"hookSpecificOutput":{"additionalContext":"ctx","hookEventName":"PreToolUse"}}
{ outcome: 'continue', updatedInput } Claude, Codex …"permissionDecision":"allow","updatedInput":{…} {"hookSpecificOutput":{"hookEventName":"PreToolUse","updatedInput":{…}}}
same Cursor {"permission":"allow","updated_input":{…}} unchanged
{ outcome: 'allow', reason?, updatedInput? } Claude, Codex (not expressible) …"permissionDecision":"allow","permissionDecisionReason":…,"updatedInput":…
same Cursor (not expressible) {"permission":"allow","updated_input"?:{…}}
{ outcome: 'ask', reason?, updatedInput? } Claude, Codex (not expressible) …"permissionDecision":"ask",…
same Cursor (not expressible) TypeError (fails closed; documented as unenforced)
{ outcome: 'deny', reason } all unchanged unchanged
permission/request { outcome: 'continue' } / empty Claude, Codex {"hookSpecificOutput":{"decision":{"behavior":"allow"},…}} / (none) (no output) / (no output)
permission/request { outcome: 'allow' } Claude, Codex (not expressible) {"hookSpecificOutput":{"decision":{"behavior":"allow"},"hookEventName":"PermissionRequest"}}

Legacy config hook (beforeTool handler returning { outcome: 'continue' }): Claude/Codex before {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}, after (no output); with updatedInput, after is {"hookSpecificOutput":{"hookEventName":"PreToolUse","updatedInput":{…}}}.

Tests

  • tests/route-unit/event-project.test.ts — new projects tool/before pass-through as no decision and only explicit allow/ask/deny as one (#461): renders through real Flight against the captured host-test PreToolUse/preToolUse envelopes (Claude 2.1.257, Codex 0.147.0, Cursor 3.18.25 in fixtures/host-lineage) and asserts the exact payload per host for pass-through, context-only, rewrite, allow, ask, deny, reason-without-decision, Cursor ask, and allow/ask on a non-tool family. Fails on origin/main (expected { hookSpecificOutput: { …(2) } } to be undefined). permission/request assertions updated: continue → no decision, allowbehavior: allow, ask rejected.
  • tests/hooks.test.ts (emitted legacy wrappers, both codecs): continuing beforeTool → empty stdout, exit 0; rewrite → updatedInput with no permissionDecision; deny unchanged.
  • tests/hook-playground-service.test.ts: playground native output for a continuing rewrite no longer carries permissionDecision.
  • Repro from the issue on the emitted examples/host-test wrappers: PreToolUse Bash ls → empty stdout, exit 0 on Claude, Codex, and Cursor.
  • pnpm typecheck ✅, pnpm lint ✅, pnpm test:unit ✅ (3026), pnpm test:route-unit ✅ (45), pnpm test:projection ✅ (145), tests/hooks.test.ts via the integration config ✅ (27), pnpm docs:site:build ✅ (parity ok).

Docs / provenance

  • website/docs/{en,zh}/guide/authoring/hooks.mdx: result vocabulary, pass-through rule, updatedInput semantics per host, wire item 5 rewritten (it previously described the bug as the rule), legacy beforeTool note.
  • Capability JSON evidence: Claude PreToolUse decision-control semantics (omitting permissionDecision = normal flow), Codex pinned output schema permissionDecision default null, Cursor ask unenforced → fails closed. Two Cursor deferred-row reasons that said "the canonical deny/continue vocabulary does not express ask" were reworded to stay true.

Changeset

agent-bundle: minor (pre-1.0 → breaking). Consumers that relied on the implicit approval must now return outcome: 'allow' explicitly, which is a changed default requiring consumer action per .changeset/README.md.

Consumer impact (no repos edited)

  • cargo-hauler (src/events/tool/before.tsxhandleBeforeShell): returns { outcome: 'continue' } for non-cargo commands, { outcome: 'deny', reason } for blocked ones, and { outcome: 'continue', updatedInput } for the cargo … → hauler rewrite. After this fix, on Claude Code and Codex: every non-cargo Bash call that was silently auto-approved goes back through the host's normal permission prompt (the correct behavior — this was the bug), and the rewritten cargo command is evaluated by the host's permission rules instead of being auto-approved, so users may now see a prompt for the rewritten hauler invocation. To keep auto-approving the rewrite, cargo-hauler can return { outcome: 'allow', updatedInput } (or ask to show the rewritten command). Cursor behavior is unchanged. No cargo-hauler issue mentions permissions (gh issue list --repo ScriptedAlchemy/cargo-hauler --search permission → only the closed test: parallelize the integration pool and stop rebuilding shared artifacts #26 about npm release), so nothing to comment there.
  • movie-library: only src/events/session/start.tsx; not affected.
  • examples/host-test: empty result on every family; now correctly writes nothing on tool/before instead of approving every tool call.
  • examples/worktree-proximity: its tool/before route returned { outcome: 'continue', reason } for proximity warnings, which the old projection delivered as permissionDecision: "allow" + permissionDecisionReason — i.e. it too auto-approved every matching call. Since a pass-through result carries no decision, reason has no channel there and the projection now rejects it; the example now warns through Agent.Context only (which it already did) and its route-unit tests assert { outcome: 'continue' }. Caught by worktree-proximity-journeys.test.ts on the first CI run.
  • Other examples declare no tool/before / beforeTool.

Review status

  • Awaiting the automated reviewer on the current head; no @codex review requests are posted from this PR by instruction.

…of allow

A tool/before route (or config beforeTool hook) that returned
outcome 'continue', no value, or only Agent.Context was delivered to
Claude Code and Codex as hookSpecificOutput.permissionDecision 'allow',
which skips the host permission prompt for every matching tool call.
Pass-through now writes no decision (and no output without context or a
rewrite); permission/request likewise answers only on an explicit
decision. The route vocabulary gains explicit 'allow' and 'ask'.

Fixes #461
@changeset-bot

changeset-bot Bot commented Sep 3, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8ce853f

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
agent-bundle Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-03T23:45:58.691134Z 54c04e0 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@pkg-pr-new

pkg-pr-new Bot commented Sep 3, 2026

Copy link
Copy Markdown
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle@481
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/create-agent-bundle@481
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@481

commit: 8ce853f

A pass-through result carries no decision, so a reason has no channel;
the proximity warnings already reach the agent as Agent.Context.
@ScriptedAlchemy
ScriptedAlchemy merged commit 66f8598 into main Sep 4, 2026
12 of 13 checks passed
@ScriptedAlchemy
ScriptedAlchemy deleted the fix/tool-before-passthrough-permission branch September 4, 2026 00:37
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.

tool/before projection emits permissionDecision "allow" for every pass-through result, auto-approving all Bash on Claude/Codex

1 participant