From 54c04e0253a64dbfb83cd0c27e1485b9fa44f72e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 23:41:32 +0000 Subject: [PATCH 1/3] fix(events): project tool/before pass-through as no decision instead 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 --- ...461-tool-before-passthrough-no-decision.md | 5 + .../adapters/capabilities/claude-2.1.250.json | 1 + .../adapters/capabilities/codex-0.147.0.json | 1 + .../capabilities/cursor-2026-08-28.json | 6 +- .../src/adapters/hook-contract.ts | 7 +- .../agent-bundle/src/events/projection.ts | 61 +++++- .../tests/hook-playground-service.test.ts | 3 +- packages/agent-bundle/tests/hooks.test.ts | 22 ++- .../tests/route-unit/event-project.test.ts | 178 +++++++++++++++++- website/docs/en/guide/authoring/hooks.mdx | 40 ++-- website/docs/zh/guide/authoring/hooks.mdx | 30 ++- 11 files changed, 308 insertions(+), 46 deletions(-) create mode 100644 .changeset/461-tool-before-passthrough-no-decision.md diff --git a/.changeset/461-tool-before-passthrough-no-decision.md b/.changeset/461-tool-before-passthrough-no-decision.md new file mode 100644 index 000000000..6913085df --- /dev/null +++ b/.changeset/461-tool-before-passthrough-no-decision.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +Stop auto-approving tool calls from a pass-through `tool/before` result. A `tool/before` event route (or a config-declared `beforeTool` hook) that returns `outcome: 'continue'`, no `value`, or only `Agent.Context` no longer projects `hookSpecificOutput.permissionDecision: "allow"` on Claude Code and Codex; it writes no decision (and no output at all when there is no context or rewrite), so the host's own permission prompt applies. `permission/request` likewise answers only on an explicit decision. The route result vocabulary gains the explicit decisions `outcome: 'allow'` and `outcome: 'ask'` for `tool/before` (`allow` also for `permission/request`) — projected as `permissionDecision: allow | ask` on Claude and Codex and `permission: allow` on Cursor, where `ask` fails closed because Cursor documents it as unenforced — and `reason` on `tool/before` now requires a decision. A `updatedInput` rewrite returned with `continue` is delivered without a decision on Claude and Codex, so the host evaluates its permission rules against the rewritten input; return `outcome: 'allow'` to keep auto-approving a rewrite. This is a minor (breaking) bump because plugins that relied on the implicit approval must now say `allow` explicitly; the previous behavior let any `tool/before` route matching `Bash` bypass the permission prompt for every shell command in the session. Fixes #461 (#468) diff --git a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json index bb673ee4c..07937a925 100644 --- a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json +++ b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json @@ -816,6 +816,7 @@ "2026-09-02: live Claude Code 2.1.257 non-interactive captures recorded PostToolUseFailure for failed Write, Read, and Bash calls; the scrubbed Bash envelope is tests/fixtures/events/claude-post-tool-use-failure.json.", "retrieved 2026-09-02: https://code.claude.com/docs/en/hooks documents PreCompact input as trigger plus nullable custom_instructions and allows decision:block with reason, while discarding continue and systemMessage. PostCompact adds compact_summary, has no decision control, and discards continue and systemMessage.", "2026-09-02: live Claude Code 2.1.257 non-interactive captures recorded PreCompact and PostCompact for manual compaction; scrubbed envelopes are tests/fixtures/events/claude-pre-compact.json and tests/fixtures/events/claude-post-compact.json.", + "retrieved 2026-09-03: https://code.claude.com/docs/en/hooks#pretooluse-decision-control documents hookSpecificOutput.permissionDecision allow|deny|ask|defer, where allow skips the permission prompt, ask forces it, and a hook that returns no decision (or no JSON at all) leaves the normal permission flow in place; updatedInput is evaluated against permission rules whether or not a decision accompanies it. The tool/before projection therefore writes permissionDecision only for an explicit allow, ask, or deny and writes nothing for a pass-through result (#461).", "The hooks reference states prompt_id requires Claude Code 2.1.196 or later; the pinned 2.1.250 release covers that input field without a version bump.", "2026-09-01: https://code.claude.com/docs/en/plugins-reference documents an agents/ component with name, description, model, effort, maxTurns, tools, disallowedTools, skills, memory, background, and isolation: worktree; #100 stage 2 deferred the agents component per the G5 narrowing in #107; the plugin.agents capability rows published by #346 are parity evidence only — the G5 gate still prohibits emitting agent components until a later stage admits them.", "2026-09-01: https://code.claude.com/docs/en/plugins \"Ship default settings with your plugin\": a plugin may include settings.json at the plugin root \"to apply default configuration when the plugin is enabled. Currently, only the `agent` and `subagentStatusLine` keys are supported.\" The file-locations table of https://code.claude.com/docs/en/plugins-reference repeats the same bound: \"Settings | settings.json | Default configuration applied when the plugin is enabled. Only the agent and subagentStatusLine keys are supported\".", diff --git a/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json b/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json index 6d3595780..7e3351b25 100644 --- a/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json +++ b/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json @@ -810,6 +810,7 @@ "retrieved 2026-09-02: rust-v0.147.0 post-compact.command.input.schema.json pins PostCompact with the same input fields and no compact summary field (sha256 4a4b3f3022c939a15ab12e95f5c5c17b18bb20f74fe962ae0a51b2a3e76e63f9); its output schema accepts only the same common fields (sha256 48355bfcb568259cf396beb6ade2ac32827f50bf6a3c20b395c337dce184cbed).", "2026-09-02: the generated compact output schemas do not document event-level runtime effects for continue:false or the other common fields, so Agent Bundle exposes no canonical compact result channel until runtime evidence pins those semantics.", "retrieved 2026-09-02: rust-v0.147.0 pre-tool-use.command.input.schema.json requires cwd, hook_event_name, model, permission_mode, session_id, tool_input (any JSON), tool_name, tool_use_id, nullable transcript_path, and turn_id (upstream sha256 fabed428f0fe75767c5700208b166da5faef4e031d601dfc8bff2f96d340c682); its output schema admits continue, decision approve|block, reason, stopReason, suppressOutput, systemMessage, and hookSpecificOutput with permissionDecision allow|deny|ask, permissionDecisionReason, updatedInput, and additionalContext (upstream sha256 e684f81c63fbb5972892f6a848b49fec68c8ce137931651093d2dd1da56a1dd6).", + "2026-09-03: the pinned pre-tool-use output schema defaults permissionDecision to null, so a hook may answer with additionalContext or updatedInput and no decision; the tool/before projection writes permissionDecision only for an explicit allow, ask, or deny and writes nothing for a pass-through result, leaving the Codex approval flow in place (#461).", "retrieved 2026-09-02: rust-v0.147.0 post-tool-use.command.input.schema.json adds tool_response (any JSON) to the PreToolUse input fields (upstream sha256 8ea1e4bccb262fad05b85c300d562d2653c5a64118d6a2c5704468fc4ea836a9); its output schema admits decision block, reason, the common fields, and hookSpecificOutput with additionalContext and updatedMCPToolOutput (upstream sha256 a823d0e2c941e98d7d3af825dfdb0b1dfa6a935696ff8b8529e8e83232a1b0c8).", "retrieved 2026-09-02: rust-v0.147.0 session-start.command.input.schema.json requires cwd, hook_event_name, model, permission_mode, session_id, source startup|resume|clear|compact, and nullable transcript_path with no turn_id (upstream sha256 690c0eef7c9f3ddcd41e24207b81b362101a300b4abec076b990a1cd79a66e20); its output schema admits the common fields plus hookSpecificOutput.additionalContext (upstream sha256 f375e6de1c59ecbabd8c1aff05a67976d0f3aa2ef061808838de4c7c20be1c71).", "retrieved 2026-09-02: rust-v0.147.0 stop.command.input.schema.json requires cwd, hook_event_name, nullable last_assistant_message, model, permission_mode, session_id, stop_hook_active, nullable transcript_path, and turn_id (upstream sha256 7db4793c404b5c46b230c27b9507eb1a558fd958689d8715221c5dd81351a06a); its output schema admits decision block, reason, and the common fields with no hookSpecificOutput (upstream sha256 dc2b30e84c97beca5825aa64ca46e1337e402781dc5a9142b67111d10523f15c). The Codex wrapper and envelope validator accept a null last_assistant_message for Stop because this pinned schema allows it.", diff --git a/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json b/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json index 47be8c194..bc90fa332 100644 --- a/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json +++ b/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json @@ -41,7 +41,7 @@ "state": "unavailable" }, "beforeMCPExecution": { - "reason": "retrieved 2026-09-02 from https://cursor.com/docs/hooks: native variant of canonical tool/before with mcp selector per the #258 selector rule (preToolUse with the ^MCP: matcher). Native input adds mcp_server_name plus url/mcp_server_url (HTTP) or command (stdio) and its output admits permission ask, which the canonical deny/continue vocabulary does not express; deferred while cloud agents cannot run it.", + "reason": "retrieved 2026-09-02 from https://cursor.com/docs/hooks: native variant of canonical tool/before with mcp selector per the #258 selector rule (preToolUse with the ^MCP: matcher). Native input adds mcp_server_name plus url/mcp_server_url (HTTP) or command (stdio) and its output admits permission ask, which the Cursor projection does not emit for preToolUse because Cursor documents it as unenforced there (#461); deferred while cloud agents cannot run it.", "state": "unavailable" }, "beforeReadFile": { @@ -49,7 +49,7 @@ "state": "unavailable" }, "beforeShellExecution": { - "reason": "retrieved 2026-09-02 from https://cursor.com/docs/hooks: native variant of canonical tool/before with shell selector per the #258 selector rule (preToolUse with the ^Shell$ matcher). Native input is { command, cwd, sandbox } and its output admits permission ask, which the canonical deny/continue vocabulary does not express; matcher is the full command string; runs in cloud agents.", + "reason": "retrieved 2026-09-02 from https://cursor.com/docs/hooks: native variant of canonical tool/before with shell selector per the #258 selector rule (preToolUse with the ^Shell$ matcher). Native input is { command, cwd, sandbox } and its output admits permission ask, which the Cursor projection does not emit for preToolUse because Cursor documents it as unenforced there (#461); matcher is the full command string; runs in cloud agents.", "state": "unavailable" }, "beforeTabFileRead": { @@ -593,7 +593,7 @@ "retrieved 2026-09-02: https://cursor.com/docs/hooks documents subagentStart input as subagent_id, subagent_type, task, parent_conversation_id, tool_call_id, subagent_model, is_parallel_worker, and optional git_branch (plus the common envelope), with output { permission allow|deny, user_message }; ask is treated as deny. The canonical agent/start deny therefore lowers to permission deny with user_message on Cursor, unlike Claude and Codex where SubagentStart cannot block; Cursor documents no additional-context channel for subagentStart, so Agent.Context fails closed there.", "retrieved 2026-09-02: https://cursor.com/docs/hooks documents subagentStop input as subagent_type, status (completed|error|aborted), task, description, summary, duration_ms, message_count, tool_call_count, loop_count, modified_files, and agent_transcript_path (string|null), with output { followup_message } consumed only when status is completed and capped by loop_limit (default 5). The canonical agent/stop deny lowers to followup_message with the reason, mirroring the stop family; no additional-context channel is documented, so Agent.Context fails closed.", "retrieved 2026-09-02: https://cursor.com/docs/hooks documents the common input envelope (conversation_id, generation_id, model, model_id?, model_params?, hook_event_name, cursor_version, workspace_roots, user_email, transcript_path) for every agent hook; generated wrappers require session_id or conversation_id plus the per-event fields and reject anything else only when documented types are violated.", - "retrieved 2026-09-02: https://cursor.com/docs/hooks documents preToolUse output permission allow|deny (ask accepted by the schema but not enforced), user_message, agent_message, and updated_input; postToolUse output updated_mcp_tool_output (MCP tools only) and additional_context; sessionStart output env and additional_context (continue/user_message accepted but unenforced); stop output followup_message. The Cursor projections emit exactly those channels.", + "retrieved 2026-09-02: https://cursor.com/docs/hooks documents preToolUse output permission allow|deny (ask accepted by the schema but not enforced, so the canonical ask outcome fails closed on Cursor), user_message, agent_message, and updated_input; postToolUse output updated_mcp_tool_output (MCP tools only) and additional_context; sessionStart output env and additional_context (continue/user_message accepted but unenforced); stop output followup_message. The Cursor projections emit exactly those channels.", "2026-09-02: local cursor-agent 2026.08.31-4057e58 capture attempts for subagentStart and subagentStop could not dispatch because the binary required unavailable authentication; the pinned official contract plus the wrapper fixture replay in tests are the evidence source for the subagent lifecycle envelopes.", "2026-09-03 (#407): hooks registration is plugin-scoped via .cursor-plugin/plugin.json `hooks` -> hooks/hooks.json; no ~/.cursor/hooks.json entry is emitted or required. Observed on Cursor 3.18.25 (isolated HOME, Agents window, shell tool call): the emitted pack's preToolUse (matcher ^Shell$), postToolUse, and stop hooks fired with CURSOR_PLUGIN_ROOT substituted and cwd = plugin root, identical to the known-loading ~/.cursor/plugins/local/tracedecay and to official marketplace plugins (cursor-public/continual-learning uses `bun run ${CURSOR_PLUGIN_ROOT}/hooks/...`). https://cursor.com/docs/hooks: 'All matching hooks from every source run', so a user-level duplicate would double-fire; Doctor reports it (AB7323). See docs/audits/2026-09-03-cursor-plugin-hooks-registration.md.", "2026-09-03 (#407): the #407 report (Cursor 3.16.21, ~/.cursor-server) is not reproducible on 3.18.25; https://cursor.com/docs/cli/changelog (August 11, 2026 release) records 'Plugin hooks run from installed plugins ... now execute and refresh when plugins reload', a vendor-side fix in that window. Installation: `install cursor --mode local` copies to ~/.cursor/plugins/local/ (https://cursor.com/docs/plugins#test-plugins-locally); `--mode marketplace` stages a committed .cursor-plugin/marketplace.json repository (https://cursor.com/docs/reference/plugins#cursor-multi-plugin-repositories) for Customize -> 'Add Plugins from Local Repository', because no plugin install deeplink exists and `cursor-agent plugin marketplace add` accepts hosted git URLs only." diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index 0839a4c54..883110b6d 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -494,10 +494,13 @@ export const encodeNativeHookPlaygroundOutput = ( } const beforeTool = canonicalEvent === 'beforeTool'; const denied = result.outcome === 'deny'; + // A continuing beforeTool handler makes no decision: the host keeps its own + // permission flow (and evaluates any rewrite through it). Only deny is + // projected as a decision (#461). const output = defined({ additionalContext: result.additionalContext, hookEventName: nativeEvent, - permissionDecision: beforeTool ? (denied ? 'deny' : 'allow') : undefined, + permissionDecision: beforeTool && denied ? 'deny' : undefined, permissionDecisionReason: beforeTool && denied ? result.reason : undefined, updatedInput: beforeTool && !denied ? result.updatedInput : undefined, }); @@ -1245,7 +1248,7 @@ export const nativeHookWrapperSource = ( ' const output = defined({', ' additionalContext: result.additionalContext,', ' hookEventName: nativeEvent,', - ' permissionDecision: canonicalEvent === "beforeTool" ? (result.outcome === "deny" ? "deny" : "allow") : undefined,', + ' permissionDecision: canonicalEvent === "beforeTool" && result.outcome === "deny" ? "deny" : undefined,', ' permissionDecisionReason: canonicalEvent === "beforeTool" && result.outcome === "deny" ? result.reason : undefined,', ' updatedInput: canonicalEvent === "beforeTool" && result.outcome !== "deny" ? result.updatedInput : undefined,', ' });', diff --git a/packages/agent-bundle/src/events/projection.ts b/packages/agent-bundle/src/events/projection.ts index b1ba4207e..57f6e95dd 100644 --- a/packages/agent-bundle/src/events/projection.ts +++ b/packages/agent-bundle/src/events/projection.ts @@ -10,8 +10,15 @@ import type { CanonicalAgentEvent, } from '../routes/public.ts'; +/** + * The route result vocabulary. `continue` (or no value at all) is the + * pass-through answer: the route has no opinion, so no decision reaches the + * host and its normal permission flow applies. `allow` and `ask` are explicit + * `tool/before` decisions (`allow` also answers `permission/request`); `deny` + * blocks. Only an explicit decision is ever projected as one (#461). + */ const resultValueSchema = z.object({ - outcome: z.enum(['continue', 'deny']).optional(), + outcome: z.enum(['continue', 'allow', 'ask', 'deny']).optional(), reason: z.string().min(1).optional(), updatedInput: z.record(z.string(), z.unknown()).optional(), }).strict(); @@ -451,6 +458,14 @@ export const projectEventDocument = ( appendContext(document.root, contexts); const additionalContext = contexts.length === 0 ? undefined : contexts.join(''); const parsedValue = document.value === undefined ? undefined : resultValueSchema.parse(document.value); + if ( + (parsedValue?.outcome === 'allow' && event !== 'tool/before' && event !== 'permission/request') + || (parsedValue?.outcome === 'ask' && event !== 'tool/before') + ) { + throw new TypeError( + `${event} does not accept outcome "${parsedValue.outcome}": allow is a tool/before or permission/request decision and ask is a tool/before decision; continue leaves the host's own flow untouched.`, + ); + } const requireDenyReason = (): string => { if (parsedValue?.outcome !== 'deny') { throw new TypeError(`${event} did not request a blocking outcome.`); @@ -648,7 +663,11 @@ export const projectEventDocument = ( if (parsedValue?.reason !== undefined && parsedValue.outcome !== 'deny') { throw new TypeError('permission/request reason is only valid when outcome is deny.'); } - if (parsedValue?.outcome === undefined) return undefined; + // https://code.claude.com/docs/en/hooks#permissionrequest-decision-control: + // a hook that returns no decision leaves the prompt to the user, so + // `continue` (and an empty result) project nothing. Only an explicit + // `allow` answers on the user's behalf (#461). + if (parsedValue?.outcome === undefined || parsedValue.outcome === 'continue') return undefined; return deepFreeze({ hookSpecificOutput: { decision: { @@ -740,26 +759,48 @@ export const projectEventDocument = ( : undefined; } if (event === 'tool/before') { + // A pass-through result (`continue` or no value) carries no decision: the + // host keeps its own permission flow, and a rewrite is evaluated by that + // flow against the rewritten input. Only an explicit allow/ask/deny is + // projected as one (#461). `reason` has no channel without a decision. + const decision = parsedValue?.outcome === undefined || parsedValue.outcome === 'continue' + ? undefined + : parsedValue.outcome; + if (parsedValue?.reason !== undefined && decision === undefined) { + throw new TypeError('tool/before reason is only valid when outcome is allow, ask, or deny.'); + } if (target === 'cursor') { - if (parsedValue?.outcome === 'deny') { + // https://cursor.com/docs/hooks#pretooluse (retrieved 2026-09-03): + // output is { permission: allow | deny, user_message?, agent_message?, + // updated_input? }; "ask" is accepted by the schema but not enforced, + // and the messages are shown only when denied. + if (decision === 'ask') { + throw new TypeError('Cursor preToolUse accepts permission "ask" in its schema but does not enforce it; ask is not projected on Cursor.'); + } + if (decision === 'deny') { return Object.freeze({ - agent_message: parsedValue.reason, + agent_message: parsedValue?.reason, permission: 'deny', - user_message: parsedValue.reason, + user_message: parsedValue?.reason, }); } - return parsedValue?.updatedInput === undefined - ? undefined - : Object.freeze({ permission: 'allow', updated_input: parsedValue.updatedInput }); + // Cursor documents updated_input only alongside a permission, so a + // rewrite without an explicit decision is delivered as allow here; on + // Claude and Codex the same rewrite carries no decision. + if (decision === undefined && parsedValue?.updatedInput === undefined) return undefined; + return Object.freeze({ + permission: 'allow', + ...(parsedValue?.updatedInput === undefined ? {} : { updated_input: parsedValue.updatedInput }), + }); } const output = { ...(additionalContext === undefined ? {} : { additionalContext }), hookEventName: nativeEvent, - permissionDecision: parsedValue?.outcome === 'deny' ? 'deny' : 'allow', + ...(decision === undefined ? {} : { permissionDecision: decision }), ...(parsedValue?.reason === undefined ? {} : { permissionDecisionReason: parsedValue.reason }), ...(parsedValue?.updatedInput === undefined ? {} : { updatedInput: parsedValue.updatedInput }), }; - return deepFreeze({ hookSpecificOutput: output }); + return Object.keys(output).length === 1 ? undefined : deepFreeze({ hookSpecificOutput: output }); } if (event === 'session/start' || event === 'tool/after') { if (additionalContext === undefined) return undefined; diff --git a/packages/agent-bundle/tests/hook-playground-service.test.ts b/packages/agent-bundle/tests/hook-playground-service.test.ts index 04fff631b..d12f11701 100644 --- a/packages/agent-bundle/tests/hook-playground-service.test.ts +++ b/packages/agent-bundle/tests/hook-playground-service.test.ts @@ -428,11 +428,12 @@ it('runs fixture and inline canonical input through the epoch-bound wrapper and tool_use_id: 'use-1', transcript_path: '/workspace/transcript.json', }, + // A continuing handler carries no permissionDecision (#461): the rewrite + // reaches the host, and the host's own permission flow evaluates it. nativeOutput: { hookSpecificOutput: { additionalContext: 'checked:one', hookEventName: 'PreToolUse', - permissionDecision: 'allow', updatedInput: { command: 'rewritten' }, }, }, diff --git a/packages/agent-bundle/tests/hooks.test.ts b/packages/agent-bundle/tests/hooks.test.ts index caf88921e..4a9fcc9c0 100644 --- a/packages/agent-bundle/tests/hooks.test.ts +++ b/packages/agent-bundle/tests/hooks.test.ts @@ -1025,7 +1025,14 @@ it('runs the embedded Codex and Claude native codecs through their published wra writeFile(join(root, 'agent-bundle.config.ts'), 'export default {};\n'), writeFile(join(root, 'package.json'), '{"type":"module"}\n'), writeFile(join(sourceRoot, 'session-start.ts'), "export default (event: { sessionId?: string }) => ({ outcome: 'continue' as const, additionalContext: event.sessionId });\n"), - writeFile(join(sourceRoot, 'check-command.ts'), "export default (event: { toolName?: string }) => ({ outcome: event.toolName === 'Bash' ? 'deny' as const : 'continue' as const, reason: 'blocked command' });\n"), + writeFile(join(sourceRoot, 'check-command.ts'), [ + 'export default (event: { toolName?: string; toolInput?: Record }) => {', + " if (event.toolName === 'Bash') return { outcome: 'deny' as const, reason: 'blocked command' };", + " if (event.toolName === 'Edit') return { outcome: 'continue' as const, updatedInput: { ...event.toolInput, file_path: '/workspace/safe.ts' } };", + " return { outcome: 'continue' as const };", + '};', + '', + ].join('\n')), writeFile(join(sourceRoot, 'record.ts'), "export default (event: { toolResponse?: unknown }) => ({ outcome: 'continue' as const, additionalContext: String(event.toolResponse) });\n"), writeFile(join(sourceRoot, 'stop.ts'), "export default () => ({ outcome: 'continue' as const });\n"), ]); @@ -1047,6 +1054,19 @@ it('runs the embedded Codex and Claude native codecs through their published wra stderr: '', stdout: '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"blocked command"}}', }); + // A continuing beforeTool handler writes no decision, so the host's own + // permission prompt still applies (#461): nothing on stdout without a + // rewrite, and a rewrite alone without permissionDecision. + await expect(runNativeHook(join(hooksRoot, 'before-tool-check-command-1f5b5818.mjs'), { + cwd: '/workspace', hook_event_name: 'PreToolUse', session_id: 'session-1', tool_input: { command: 'ls' }, tool_name: 'Write', tool_use_id: 'use-1', transcript_path: '/workspace/transcript.json', + })).resolves.toEqual({ code: 0, stderr: '', stdout: '' }); + await expect(runNativeHook(join(hooksRoot, 'before-tool-check-command-1f5b5818.mjs'), { + cwd: '/workspace', hook_event_name: 'PreToolUse', session_id: 'session-1', tool_input: { file_path: '/etc/passwd' }, tool_name: 'Edit', tool_use_id: 'use-1', transcript_path: '/workspace/transcript.json', + })).resolves.toEqual({ + code: 0, + stderr: '', + stdout: '{"hookSpecificOutput":{"hookEventName":"PreToolUse","updatedInput":{"file_path":"/workspace/safe.ts"}}}', + }); await expect(runNativeHook(join(hooksRoot, 'after-tool-record-87785f02.mjs'), { cwd: '/workspace', hook_event_name: 'PostToolUse', session_id: 'session-1', tool_input: {}, tool_response: { value: 'observed' }, tool_name: 'Write', tool_use_id: 'use-2', transcript_path: '/workspace/transcript.json', })).resolves.toEqual({ diff --git a/packages/agent-bundle/tests/route-unit/event-project.test.ts b/packages/agent-bundle/tests/route-unit/event-project.test.ts index 6d5748dba..80da31742 100644 --- a/packages/agent-bundle/tests/route-unit/event-project.test.ts +++ b/packages/agent-bundle/tests/route-unit/event-project.test.ts @@ -595,20 +595,36 @@ it('projects permission/request decisions through the pinned PermissionRequest o }); } + // Only an explicit allow answers on the user's behalf; `continue` and an + // empty result leave the prompt to the user (#461). const allowed = await renderRoute({ - default: async () => createElement(Agent.Result, { value: { outcome: 'continue' } }), + default: async () => createElement(Agent.Result, { value: { outcome: 'allow' } }), }, routeInput); - expect(projectEventDocument(allowed.document, 'permission/request', 'claude', 'PermissionRequest')).toEqual({ - hookSpecificOutput: { - decision: { behavior: 'allow' }, - hookEventName: 'PermissionRequest', - }, - }); + for (const target of ['claude', 'codex']) { + expect(projectEventDocument(allowed.document, 'permission/request', target, 'PermissionRequest')).toEqual({ + hookSpecificOutput: { + decision: { behavior: 'allow' }, + hookEventName: 'PermissionRequest', + }, + }); + } + const continued = await renderRoute({ + default: async () => createElement(Agent.Result, { value: { outcome: 'continue' } }), + }, routeInput); const observed = await renderRoute({ default: async () => createElement(Agent.Result), }, routeInput); - expect(projectEventDocument(observed.document, 'permission/request', 'codex', 'PermissionRequest')).toBeUndefined(); + for (const target of ['claude', 'codex']) { + expect(projectEventDocument(continued.document, 'permission/request', target, 'PermissionRequest')).toBeUndefined(); + expect(projectEventDocument(observed.document, 'permission/request', target, 'PermissionRequest')).toBeUndefined(); + } + + const asked = await renderRoute({ + default: async () => createElement(Agent.Result, { value: { outcome: 'ask' } }), + }, routeInput); + expect(() => projectEventDocument(asked.document, 'permission/request', 'claude', 'PermissionRequest')) + .toThrow(/permission\/request does not accept outcome "ask"/u); const rewritten = await renderRoute({ default: async () => createElement(Agent.Result, { value: { updatedInput: { command: 'rm -r build' } } }), @@ -820,3 +836,149 @@ it('projects the stage-4 Claude-only families with their documented decision cha expect(() => projectEventDocument(idleContextual.document, 'agent/idle', 'claude', 'TeammateIdle')) .toThrow(/no documented additional-context channel/u); }); + +it('projects tool/before pass-through as no decision and only explicit allow/ask/deny as one (#461)', async () => { + // Native envelopes are the captured host-test PreToolUse/preToolUse + // records in fixtures/host-lineage (Claude 2.1.257, Codex 0.147.0, + // Cursor 3.18.25), trimmed to the validated fields. + const claudeNative = { + cwd: '/tmp/host-test/claude-workspace', + hook_event_name: 'PreToolUse', + permission_mode: 'default', + session_id: '7f7a50ca-3609-4612-8db1-a34c8985088a', + tool_input: { command: 'rm -rf build' }, + tool_name: 'Bash', + tool_use_id: 'toolu_01Nj8kitmmxAGYiCXF5NA9ZB', + transcript_path: '/tmp/host-test/claude-home/.claude/projects/-tmp-host-test-claude-workspace/7f7a50ca.jsonl', + }; + const codexNative = { + cwd: '/tmp/host-test/codex-workspace', + hook_event_name: 'PreToolUse', + model: 'gpt-5.6-sol', + permission_mode: 'default', + session_id: '01a06660-110e-7290-8d1c-8ef1b2b68fc2', + tool_input: { command: 'rm -rf build' }, + tool_name: 'Bash', + tool_use_id: 'exec-95e92c51-9373-4a8e-9cc1-5f2bf32efee1', + transcript_path: null, + turn_id: '01a06660-1179-7bd2-bb02-d4cac726b2a0', + }; + const cursorNative = { + conversation_id: 'b60ae0c1-2f85-4c4d-b3e5-b512f9b06e4c', + cursor_version: '3.18.25', + cwd: '/tmp/host-test/cursor-workspace', + hook_event_name: 'preToolUse', + session_id: 'b60ae0c1-2f85-4c4d-b3e5-b512f9b06e4c', + tool_input: { command: 'rm -rf build' }, + tool_name: 'Shell', + tool_use_id: 'call-130a53a3-5718-473b-8101-a9c73231b7be-0', + }; + const hosts = [ + { native: claudeNative, nativeEvent: 'PreToolUse', revision: '2.1.250', target: 'claude' }, + { native: codexNative, nativeEvent: 'PreToolUse', revision: '0.147.0', target: 'codex' }, + { native: cursorNative, nativeEvent: 'preToolUse', revision: '2026-08-28', target: 'cursor' }, + ] as const; + const render = async (host: (typeof hosts)[number], value?: JsonValue, context?: string) => { + const native = validateNativeEventEnvelope(host.native, { canonicalEvent: 'tool/before', nativeEvent: host.nativeEvent, target: host.target }); + const props = createCanonicalEventProps('tool/before', native, host.target, host.nativeEvent, host.revision, new AbortController().signal); + const rendered = await renderRoute({ + default: async () => createElement( + Agent.Result, + value === undefined ? null : { value }, + context === undefined ? null : createElement(Agent.Context, null, context), + ), + }, { + input: { canonical: props.canonical, native: props.native }, + kind: 'event-route', + routeId: 'event:tool/before', + }); + return projectEventDocument(rendered.document, 'tool/before', host.target, host.nativeEvent, native); + }; + const rewrite = { command: 'rm -r build' }; + + for (const host of hosts) { + // Pass-through: an empty result, `continue`, and an unrelated value all + // leave the host's own permission flow untouched — nothing is written. + expect(await render(host)).toBeUndefined(); + expect(await render(host, { outcome: 'continue' })).toBeUndefined(); + // A reason has no channel without a decision. + await expect(render(host, { outcome: 'continue', reason: 'Looks fine.' })).rejects + .toThrow(/tool\/before reason is only valid when outcome is allow, ask, or deny/u); + } + + for (const host of hosts.filter((candidate) => candidate.target !== 'cursor')) { + // Context alone rides the context channel with no permissionDecision. + expect(await render(host, undefined, 'Build tree is dirty.')).toEqual({ + hookSpecificOutput: { additionalContext: 'Build tree is dirty.', hookEventName: 'PreToolUse' }, + }); + expect(await render(host, { outcome: 'continue' }, 'Build tree is dirty.')).toEqual({ + hookSpecificOutput: { additionalContext: 'Build tree is dirty.', hookEventName: 'PreToolUse' }, + }); + // A rewrite without a decision is evaluated by the host's permission + // rules against the rewritten input; it does not imply approval. + expect(await render(host, { outcome: 'continue', updatedInput: rewrite })).toEqual({ + hookSpecificOutput: { hookEventName: 'PreToolUse', updatedInput: rewrite }, + }); + // Explicit decisions project exactly the host field. + expect(await render(host, { outcome: 'allow' })).toEqual({ + hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow' }, + }); + expect(await render(host, { outcome: 'allow', reason: 'Trusted build script.', updatedInput: rewrite }, 'Rewritten.')).toEqual({ + hookSpecificOutput: { + additionalContext: 'Rewritten.', + hookEventName: 'PreToolUse', + permissionDecision: 'allow', + permissionDecisionReason: 'Trusted build script.', + updatedInput: rewrite, + }, + }); + expect(await render(host, { outcome: 'ask', reason: 'Confirm the rewritten command.', updatedInput: rewrite })).toEqual({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'ask', + permissionDecisionReason: 'Confirm the rewritten command.', + updatedInput: rewrite, + }, + }); + expect(await render(host, { outcome: 'deny', reason: 'Destructive command blocked.' })).toEqual({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: 'Destructive command blocked.', + }, + }); + } + + // Cursor: https://cursor.com/docs/hooks#pretooluse documents permission + // allow|deny with updated_input; "ask" is accepted by the schema but not + // enforced, so it fails closed instead of being silently downgraded. + const cursor = hosts[2]; + expect(await render(cursor, { outcome: 'allow' })).toEqual({ permission: 'allow' }); + expect(await render(cursor, { outcome: 'allow', updatedInput: rewrite })).toEqual({ permission: 'allow', updated_input: rewrite }); + expect(await render(cursor, { outcome: 'continue', updatedInput: rewrite })).toEqual({ permission: 'allow', updated_input: rewrite }); + expect(await render(cursor, { outcome: 'deny', reason: 'Destructive command blocked.' })).toEqual({ + agent_message: 'Destructive command blocked.', + permission: 'deny', + user_message: 'Destructive command blocked.', + }); + await expect(render(cursor, { outcome: 'ask', reason: 'Confirm.' })).rejects + .toThrow(/Cursor preToolUse accepts permission "ask" in its schema but does not enforce it/u); + + // allow and ask are tool/before decisions; every other family rejects them + // instead of treating them as continue. + const stopProps = createCanonicalEventProps( + 'stop', + { cwd: '/workspace', hook_event_name: 'Stop', session_id: 'session-1', stop_hook_active: false, transcript_path: '/workspace/transcript.jsonl' }, + 'claude', + 'Stop', + '2.1.250', + new AbortController().signal, + ); + for (const outcome of ['allow', 'ask']) { + const rendered = await renderRoute({ + default: async () => createElement(Agent.Result, { value: { outcome } }), + }, { input: { canonical: stopProps.canonical, native: stopProps.native }, kind: 'event-route', routeId: 'event:stop' }); + expect(() => projectEventDocument(rendered.document, 'stop', 'claude', 'Stop')) + .toThrow(new RegExp(`stop does not accept outcome "${outcome}"`, 'u')); + } +}); diff --git a/website/docs/en/guide/authoring/hooks.mdx b/website/docs/en/guide/authoring/hooks.mdx index d50914425..208381261 100644 --- a/website/docs/en/guide/authoring/hooks.mdx +++ b/website/docs/en/guide/authoring/hooks.mdx @@ -119,7 +119,11 @@ four keys are accepted, and each one is checked: Per-event restrictions are enforced, not documented-and-hoped: - `sessionStart`, `afterTool`, and `agentStart` cannot deny, stop, or replace input. -- `beforeTool` cannot stop, and cannot replace input while denying. +- `beforeTool` cannot stop, and cannot replace input while denying. A continuing `beforeTool` + handler makes **no** permission decision: nothing is written to the host's decision field, so + its normal permission prompt still applies, and a `updatedInput` rewrite is evaluated by that + flow against the rewritten input. Only `deny` is projected as a decision. To approve a call + explicitly, use an [event route](#event-routes) and return `outcome: 'allow'`. - `stop` accepts only `continue`, or `deny` with a reason. - `agentStop` cannot stop the parent flow or replace input, and on Codex it cannot add context. @@ -191,13 +195,25 @@ axis leaves it unavailable. The route answers through its rendered document. `Agent.Context` text becomes the host's additional-context channel, and `Agent.Result`'s `value` may carry -`{ outcome: 'continue' | 'deny', reason?, updatedInput? }`. The projection is per event and per -host, and illegal combinations throw before anything reaches the host: `session/end`, -`compact/after`, `tool/failure`, and `workspace/open` are observation-only everywhere; a denied -`tool/before` becomes `hookSpecificOutput.permissionDecision` on Claude and Codex but -`{ permission: 'deny', … }` on Cursor; a denied `stop` becomes `{ decision: 'block', reason }` or -Cursor's `followup_message`. Which families each host supports is the generated -[Event and hook matrix](../../reference/events.md). +`{ outcome: 'continue' | 'allow' | 'ask' | 'deny', reason?, updatedInput? }`. `continue` — or +no `value` at all — is the pass-through answer: the route has no opinion, so **no decision** is +written and the host's normal permission flow applies. Only an explicit `allow`, `ask`, or +`deny` is projected as one. The projection is per event and per host, and illegal combinations +throw before anything reaches the host: `session/end`, `compact/after`, `tool/failure`, and +`workspace/open` are observation-only everywhere; a decided `tool/before` becomes +`hookSpecificOutput.permissionDecision: 'allow' | 'ask' | 'deny'` on Claude and Codex but +`{ permission: 'allow' | 'deny', … }` on Cursor, which documents `ask` as unenforced and +therefore rejects it; a denied `stop` becomes `{ decision: 'block', reason }` or Cursor's +`followup_message`. `allow` and `ask` are `tool/before` decisions (`allow` also answers +`permission/request` on the user's behalf); every other family rejects them. Which families each +host supports is the generated [Event and hook matrix](../../reference/events.md). + +`updatedInput` on `tool/before` rewrites the pending call and is independent of the decision. On +Claude and Codex a rewrite returned with `continue` carries no `permissionDecision`, so the host +evaluates its permission rules against the rewritten input; pair it with `allow` to auto-approve +the rewrite or with `ask` to show the rewritten input to the user. Cursor documents +`updated_input` only alongside a `permission`, so a rewrite is delivered as `permission: 'allow'` +there. Event routes reach the twenty canonical families (`session/end`, `prompt/submit`, `compact/before`, `permission/request`, …); config-declared `hooks` cover only the seven @@ -230,10 +246,10 @@ process, so hooks share state with tools: 4. **Runtime → wrapper.** One JSON reply: `{ status: 'ok', output }`, or `{ status: 'error', code }` where `code` is `epoch-mismatch`, `invalid-message`, or `runtime-failed`. -5. **Wrapper → host (stdout).** The host-native response. On `tool/before` the wrapper always - answers with an explicit allow-or-deny decision in the host's own field names (`allow` unless - the route denied) even when the route renders no decision; silence is reserved for - observation-only families. +5. **Wrapper → host (stdout).** The host-native response, or nothing at all. A route that + renders no decision and no context writes no output and exits 0, so the host proceeds + exactly as it would without the hook; on `tool/before` that means its own permission prompt. + A decision field is written only when the route returned `allow`, `ask`, or `deny`. Failure is closed by default. The only fallback is `fallback: 'standalone'` on a route that also compiled its standalone form, and it fires **only** for `runtime-unavailable` (no live diff --git a/website/docs/zh/guide/authoring/hooks.mdx b/website/docs/zh/guide/authoring/hooks.mdx index d83bcfb45..c50529a42 100644 --- a/website/docs/zh/guide/authoring/hooks.mdx +++ b/website/docs/zh/guide/authoring/hooks.mdx @@ -111,7 +111,10 @@ export default (event: SessionStartEvent) => ({ 逐事件的限制是被强制执行的,而不是「写在文档里、希望有人遵守」: - `sessionStart`、`afterTool` 与 `agentStart` 不能拒绝、停止或替换输入。 -- `beforeTool` 不能停止,也不能在拒绝的同时替换输入。 +- `beforeTool` 不能停止,也不能在拒绝的同时替换输入。继续(`continue`)的 `beforeTool` 处理器 + **不做**任何权限决定:宿主的决定字段不会被写入,因此其正常的权限提示仍然适用;`updatedInput` + 改写会由该流程针对改写后的输入进行评估。只有 `deny` 会被投影为决定。若要显式批准一次调用,请使用 + [事件路由](#事件路由)并返回 `outcome: 'allow'`。 - `stop` 只接受 `continue`,或带原因的 `deny`。 - `agentStop` 不能停止父流程或替换输入;在 Codex 上也不能追加上下文。 @@ -177,11 +180,20 @@ export default async function AfterFileEdit({ canonical, native, signal }: Agent 信封的冻结快照。`canonical` 中没有任何伪造:宿主未报告的轴保持不可用。 路由通过它渲染出的文档作答。`Agent.Context` 文本成为宿主的附加上下文通道,`Agent.Result` 的 `value` 可以 -携带 `{ outcome: 'continue' | 'deny', reason?, updatedInput? }`。投影按事件、按宿主进行,非法组合在任何 -内容到达宿主之前就会抛错:`session/end`、`compact/after`、`tool/failure` 与 `workspace/open` 在所有宿主上 -都只可观察;被拒绝的 `tool/before` 在 Claude 与 Codex 上成为 `hookSpecificOutput.permissionDecision`, -在 Cursor 上则是 `{ permission: 'deny', … }`;被拒绝的 `stop` 成为 `{ decision: 'block', reason }` 或 -Cursor 的 `followup_message`。每个宿主支持哪些事件族,见生成的[事件与钩子矩阵](../../reference/events.md)。 +携带 `{ outcome: 'continue' | 'allow' | 'ask' | 'deny', reason?, updatedInput? }`。`continue`——或完全不给 +`value`——是「放行」的回答:路由没有意见,因此**不会写入任何决定**,宿主的正常权限流程照常适用。只有显式的 +`allow`、`ask` 或 `deny` 才会被投影为决定。投影按事件、按宿主进行,非法组合在任何内容到达宿主之前就会抛错: +`session/end`、`compact/after`、`tool/failure` 与 `workspace/open` 在所有宿主上都只可观察;做出决定的 +`tool/before` 在 Claude 与 Codex 上成为 `hookSpecificOutput.permissionDecision: 'allow' | 'ask' | 'deny'`, +在 Cursor 上则是 `{ permission: 'allow' | 'deny', … }`——Cursor 文档称 `ask` 不会被强制执行,因此会拒绝它; +被拒绝的 `stop` 成为 `{ decision: 'block', reason }` 或 Cursor 的 `followup_message`。`allow` 与 `ask` 是 +`tool/before` 的决定(`allow` 也可以代表用户回答 `permission/request`);其他任何事件族都会拒绝它们。每个 +宿主支持哪些事件族,见生成的[事件与钩子矩阵](../../reference/events.md)。 + +`tool/before` 上的 `updatedInput` 改写待执行的调用,并且与决定相互独立。在 Claude 与 Codex 上,随 `continue` +返回的改写不携带 `permissionDecision`,宿主会针对改写后的输入评估其权限规则;与 `allow` 搭配可自动批准该改写, +与 `ask` 搭配则把改写后的输入展示给用户确认。Cursor 只在与 `permission` 同时出现时记载 `updated_input`,因此 +改写在那里以 `permission: 'allow'` 交付。 事件路由可以触达全部二十个规范事件族(`session/end`、`prompt/submit`、`compact/before`、 `permission/request`……);配置声明的 `hooks` 只覆盖上面列出的七个。 @@ -208,9 +220,9 @@ target 还会为 Cursor 写出 `hooks/hooks-cursor.json` 与 `hooks/.curso react-server worker 渲染路由组件。 4. **运行时 → 包装层。** 一条 JSON 回复:`{ status: 'ok', output }`,或 `{ status: 'error', code }`, 其中 `code` 为 `epoch-mismatch`、`invalid-message` 或 `runtime-failed`。 -5. **包装层 → 宿主(stdout)。** 宿主原生的响应。对 `tool/before`,即便路由没有渲染任何决定,包装层也 - 总会给出显式的允许/拒绝决定(除非路由拒绝,否则为允许),以宿主自己的字段名表达;沉默只保留给只可 - 观察的事件族。 +5. **包装层 → 宿主(stdout)。** 宿主原生的响应,或者什么都不输出。没有渲染任何决定与上下文的路由不写 + 任何输出并以 0 退出,宿主会像没有这个钩子一样继续;对 `tool/before` 而言,这意味着它自己的权限提示。 + 只有当路由返回 `allow`、`ask` 或 `deny` 时,才会写入决定字段。 失败默认是关闭的。唯一的回退是同时编译了独立形态的路由上的 `fallback: 'standalone'`,而且它**只**在 `runtime-unavailable`(没有活的 socket)时触发。`runtime-timeout`、`epoch-mismatch`、`invalid-message` From a667b7c7743a7a087ada96d7979ad96e7bd0b0a2 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 23:42:41 +0000 Subject: [PATCH 2/3] chore: reference #481 in the changeset --- .changeset/461-tool-before-passthrough-no-decision.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/461-tool-before-passthrough-no-decision.md b/.changeset/461-tool-before-passthrough-no-decision.md index 6913085df..3dfe9458d 100644 --- a/.changeset/461-tool-before-passthrough-no-decision.md +++ b/.changeset/461-tool-before-passthrough-no-decision.md @@ -2,4 +2,4 @@ "agent-bundle": minor --- -Stop auto-approving tool calls from a pass-through `tool/before` result. A `tool/before` event route (or a config-declared `beforeTool` hook) that returns `outcome: 'continue'`, no `value`, or only `Agent.Context` no longer projects `hookSpecificOutput.permissionDecision: "allow"` on Claude Code and Codex; it writes no decision (and no output at all when there is no context or rewrite), so the host's own permission prompt applies. `permission/request` likewise answers only on an explicit decision. The route result vocabulary gains the explicit decisions `outcome: 'allow'` and `outcome: 'ask'` for `tool/before` (`allow` also for `permission/request`) — projected as `permissionDecision: allow | ask` on Claude and Codex and `permission: allow` on Cursor, where `ask` fails closed because Cursor documents it as unenforced — and `reason` on `tool/before` now requires a decision. A `updatedInput` rewrite returned with `continue` is delivered without a decision on Claude and Codex, so the host evaluates its permission rules against the rewritten input; return `outcome: 'allow'` to keep auto-approving a rewrite. This is a minor (breaking) bump because plugins that relied on the implicit approval must now say `allow` explicitly; the previous behavior let any `tool/before` route matching `Bash` bypass the permission prompt for every shell command in the session. Fixes #461 (#468) +Stop auto-approving tool calls from a pass-through `tool/before` result. A `tool/before` event route (or a config-declared `beforeTool` hook) that returns `outcome: 'continue'`, no `value`, or only `Agent.Context` no longer projects `hookSpecificOutput.permissionDecision: "allow"` on Claude Code and Codex; it writes no decision (and no output at all when there is no context or rewrite), so the host's own permission prompt applies. `permission/request` likewise answers only on an explicit decision. The route result vocabulary gains the explicit decisions `outcome: 'allow'` and `outcome: 'ask'` for `tool/before` (`allow` also for `permission/request`) — projected as `permissionDecision: allow | ask` on Claude and Codex and `permission: allow` on Cursor, where `ask` fails closed because Cursor documents it as unenforced — and `reason` on `tool/before` now requires a decision. A `updatedInput` rewrite returned with `continue` is delivered without a decision on Claude and Codex, so the host evaluates its permission rules against the rewritten input; return `outcome: 'allow'` to keep auto-approving a rewrite. This is a minor (breaking) bump because plugins that relied on the implicit approval must now say `allow` explicitly; the previous behavior let any `tool/before` route matching `Bash` bypass the permission prompt for every shell command in the session. Fixes #461 (#481) From 8ce853f49a4ff9d3fb3c0a5e01c32e90dff3c51e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 00:12:04 +0000 Subject: [PATCH 3/3] fix(examples): worktree-proximity tool/before warns through context only A pass-through result carries no decision, so a reason has no channel; the proximity warnings already reach the agent as Agent.Context. --- .../worktree-proximity/src/events/tool/before.tsx | 10 +++++----- .../tests/route-unit/routes.test.ts | 12 ++++-------- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/examples/worktree-proximity/src/events/tool/before.tsx b/examples/worktree-proximity/src/events/tool/before.tsx index 803e175ad..14f1a95b3 100644 --- a/examples/worktree-proximity/src/events/tool/before.tsx +++ b/examples/worktree-proximity/src/events/tool/before.tsx @@ -56,7 +56,7 @@ export default async function BeforeTool({ }); if (topologyResult.state === 'unavailable') { return ( - + {topologyResult.reason} ); @@ -91,10 +91,10 @@ export default async function BeforeTool({ : [noticeResult.reason]; const warnings = resolution.conflicts.map((conflict) => `Proximity warning for ${resolution.actor.id}: ${conflict.summary}`); - const reason = warnings.join(' '); - const value: JsonValue = reason === '' - ? { outcome: 'continue' as const } - : { outcome: 'continue' as const, reason }; + // Proximity warns; it never decides. `continue` leaves the host's own + // permission flow untouched, and the warnings reach the agent as context + // (a `reason` needs a decision to travel with, so none is attached). + const value: JsonValue = { outcome: 'continue' as const }; return ( diff --git a/examples/worktree-proximity/tests/route-unit/routes.test.ts b/examples/worktree-proximity/tests/route-unit/routes.test.ts index 88dfb8c78..255d102cf 100644 --- a/examples/worktree-proximity/tests/route-unit/routes.test.ts +++ b/examples/worktree-proximity/tests/route-unit/routes.test.ts @@ -214,10 +214,9 @@ describe('worktree proximity journeys', () => { .toHaveStatus('success') .toContainContext('Proximity warning') .toContainContext('src/shared.ts'); - expect(rendered.document.value).toMatchObject({ - outcome: 'continue', - reason: expect.stringContaining('src/shared.ts'), - }); + // The warning travels as context only; a pass-through result carries no + // decision and therefore no reason, so the host's permission flow is untouched. + expect(rendered.document.value).toEqual({ outcome: 'continue' }); const bindings = await runtimeState.requestBindings(); try { @@ -398,9 +397,6 @@ describe('worktree proximity journeys', () => { expectDocument(rendered) .toHaveStatus('success') .toContainContext('state unavailable'); - expect(rendered.document.value).toMatchObject({ - outcome: 'continue', - reason: expect.stringContaining('state unavailable'), - }); + expect(rendered.document.value).toEqual({ outcome: 'continue' }); }); });