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..3dfe9458d --- /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 (#481) 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' }); }); }); 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`