Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/461-tool-before-passthrough-no-decision.md
Original file line number Diff line number Diff line change
@@ -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)
10 changes: 5 additions & 5 deletions examples/worktree-proximity/src/events/tool/before.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export default async function BeforeTool({
});
if (topologyResult.state === 'unavailable') {
return (
<Agent.Result value={{ outcome: 'continue', reason: topologyResult.reason }}>
<Agent.Result value={{ outcome: 'continue' }}>
<Agent.Context>{topologyResult.reason}</Agent.Context>
</Agent.Result>
);
Expand Down Expand Up @@ -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 (
<Agent.Result value={value}>
Expand Down
12 changes: 4 additions & 8 deletions examples/worktree-proximity/tests/route-unit/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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' });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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\".",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,15 @@
"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": {
"reason": "retrieved 2026-09-02 from https://cursor.com/docs/hooks: native variant of canonical tool/before with file.read selector per the #258 selector rule (preToolUse with the ^Read$ matcher). Native input adds content and attachments and its output is { permission allow|deny, user_message }; failClosed is documented for it; matcher is the tool type (TabRead, Read); runs in cloud agents.",
"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": {
Expand Down Expand Up @@ -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/<name> (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."
Expand Down
7 changes: 5 additions & 2 deletions packages/agent-bundle/src/adapters/hook-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down Expand Up @@ -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,',
' });',
Expand Down
Loading
Loading