From 0109c548e17ee651ababce5431d95536e8e1ac73 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 03:06:05 +0000 Subject: [PATCH] feat(events): promote permission and stop-failure route families (#258 v2 s3) --- .../promote-permission-stop-failure-events.md | 5 + examples/rsc-agent-runtime/README.md | 3 + .../adapters/capabilities/claude-2.1.250.json | 3 + .../adapters/capabilities/codex-0.147.0.json | 13 ++ .../capabilities/cursor-2026-08-28.json | 12 ++ .../adapters/capabilities/portable-1.0.0.json | 12 ++ .../src/adapters/hook-contract.ts | 30 ++++ .../adapters/schemas/codex/PROVENANCE.json | 2 +- ...rmission-request.command.input.schema.json | 67 +++++++++ ...mission-request.command.output.schema.json | 91 +++++++++++ packages/agent-bundle/src/config/normalize.ts | 3 + packages/agent-bundle/src/core/types.ts | 3 + .../agent-bundle/src/events/projection.ts | 74 +++++++++ packages/agent-bundle/src/routes/public.ts | 3 + .../tests/adapter-metadata.test.ts | 4 +- .../agent-bundle/tests/event-project.test.ts | 79 ++++++++++ .../events/claude-permission-denied.json | 13 ++ .../events/claude-permission-request.json | 12 ++ .../fixtures/events/claude-stop-failure.json | 9 ++ .../events/codex-permission-request.json | 13 ++ .../tests/route-unit/event-project.test.ts | 142 ++++++++++++++++++ 21 files changed, 590 insertions(+), 3 deletions(-) create mode 100644 .changeset/promote-permission-stop-failure-events.md create mode 100644 packages/agent-bundle/src/adapters/schemas/codex/generated/permission-request.command.input.schema.json create mode 100644 packages/agent-bundle/src/adapters/schemas/codex/generated/permission-request.command.output.schema.json create mode 100644 packages/agent-bundle/tests/fixtures/events/claude-permission-denied.json create mode 100644 packages/agent-bundle/tests/fixtures/events/claude-permission-request.json create mode 100644 packages/agent-bundle/tests/fixtures/events/claude-stop-failure.json create mode 100644 packages/agent-bundle/tests/fixtures/events/codex-permission-request.json diff --git a/.changeset/promote-permission-stop-failure-events.md b/.changeset/promote-permission-stop-failure-events.md new file mode 100644 index 000000000..018731483 --- /dev/null +++ b/.changeset/promote-permission-stop-failure-events.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Promote the `permission/request`, `permission/denied`, and `stop/failure` canonical event-route families. `permission/request` projects allow/deny decisions through the pinned PermissionRequest output contract on Claude Code and Codex (input rewrite stays fail-closed as reserved upstream); `permission/denied` and `stop/failure` are observation-only Claude families with fail-closed rejection of decision or context output. Codex permission-request wire schemas are byte-pinned from the rust-v0.147.0 tag; hosts without a documented native event carry dated `unavailable` capability rows. diff --git a/examples/rsc-agent-runtime/README.md b/examples/rsc-agent-runtime/README.md index 540e84495..6f792d898 100644 --- a/examples/rsc-agent-runtime/README.md +++ b/examples/rsc-agent-runtime/README.md @@ -237,7 +237,10 @@ Host/Origin allowlists mitigate DNS rebinding and cross-origin requests, but the | `tool/failure` | `postToolUseFailure` (observe-only) | `PostToolUseFailure` (context) | Unavailable | | `compact/before` | `preCompact` (observe-only; native `user_message` not modeled) | `PreCompact` (deny) | `PreCompact` (observe-only) | | `compact/after` | Unavailable | `PostCompact` (observe-only) | `PostCompact` (observe-only; no summary field) | +| `permission/request` | Unavailable | `PermissionRequest` (allow/deny) | `PermissionRequest` (allow/deny) | +| `permission/denied` | Unavailable | `PermissionDenied` (observe-only) | Unavailable | | `stop` | Supported | `Stop` | `Stop` | +| `stop/failure` | Unavailable | `StopFailure` (observe-only) | Unavailable | | `agent/start` | `subagentStart` | `SubagentStart` | `SubagentStart` | | `agent/stop` | `subagentStop` | `SubagentStop` | `SubagentStop` | | `workspace/open` | Supported (observe-only; native `pluginPaths` return not modeled) | Unavailable | Unavailable | 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 d07d33dba..cc9eacee0 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 @@ -27,10 +27,13 @@ "agent/stop": { "nativeEvent": "SubagentStop", "state": "supported" }, "compact/after": { "nativeEvent": "PostCompact", "state": "supported" }, "compact/before": { "nativeEvent": "PreCompact", "state": "supported" }, + "permission/denied": { "nativeEvent": "PermissionDenied", "state": "supported" }, + "permission/request": { "nativeEvent": "PermissionRequest", "state": "supported" }, "prompt/submit": { "nativeEvent": "UserPromptSubmit", "state": "supported" }, "session/end": { "nativeEvent": "SessionEnd", "state": "supported" }, "session/start": { "nativeEvent": "SessionStart", "state": "supported" }, "stop": { "nativeEvent": "Stop", "state": "supported" }, + "stop/failure": { "nativeEvent": "StopFailure", "state": "supported" }, "tool/after": { "nativeEvent": "PostToolUse", "state": "supported" }, "tool/before": { "nativeEvent": "PreToolUse", "state": "supported" }, "tool/failure": { "nativeEvent": "PostToolUseFailure", "state": "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 bb067792d..7c299779b 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 @@ -27,10 +27,19 @@ "agent/stop": { "nativeEvent": "SubagentStop", "state": "supported" }, "compact/after": { "nativeEvent": "PostCompact", "state": "supported" }, "compact/before": { "nativeEvent": "PreCompact", "state": "supported" }, + "permission/denied": { + "reason": "retrieved 2026-09-02: the complete rust-v0.147.0 generated hook schema directory contains no permission-denied event.", + "state": "unavailable" + }, + "permission/request": { "nativeEvent": "PermissionRequest", "state": "supported" }, "prompt/submit": { "nativeEvent": "UserPromptSubmit", "state": "supported" }, "session/end": { "nativeEvent": "SessionEnd", "state": "supported" }, "session/start": { "nativeEvent": "SessionStart", "state": "supported" }, "stop": { "nativeEvent": "Stop", "state": "supported" }, + "stop/failure": { + "reason": "retrieved 2026-09-02: the complete rust-v0.147.0 generated hook schema directory contains no stop-failure event.", + "state": "unavailable" + }, "tool/after": { "nativeEvent": "PostToolUse", "state": "supported" }, "tool/before": { "nativeEvent": "PreToolUse", "state": "supported" }, "tool/failure": { @@ -263,6 +272,8 @@ "liveRevision": "codex-cli 0.147.0 app-server protocol", "matchingRelativePaths": [], "missingPinnedRelativePaths": [ + "permission-request.command.input.schema.json", + "permission-request.command.output.schema.json", "post-compact.command.input.schema.json", "post-compact.command.output.schema.json", "pre-compact.command.input.schema.json", @@ -276,6 +287,8 @@ "user-prompt-submit.command.output.schema.json" ], "pinnedRepositorySha256": { + "permission-request.command.input.schema.json": "75c73d7a38cfc0e73ef06bd1fc506a44d25874522069ec4fb85e0bf1e7d6b8fb", + "permission-request.command.output.schema.json": "749c73245b4b6d43537c3049f76720ab1c2bd48d7e4752b744b376925b9d57a1", "post-compact.command.input.schema.json": "d5cecd14bd2ca18605ba8209108f76291f886ffe3cb4762d70e712c148836f31", "post-compact.command.output.schema.json": "811b7ae2a4b277cd51c9df989f347e494fa981d01e346bc2a757506e97734882", "pre-compact.command.input.schema.json": "5728b5da4c63e1e07f2d53ac8b2adc18306cd3dc8b01b556fc422e75b32a8734", 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 ddcd1d73c..6a1a243e4 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 @@ -30,6 +30,14 @@ "state": "unavailable" }, "compact/before": { "nativeEvent": "preCompact", "state": "supported" }, + "permission/denied": { + "reason": "retrieved 2026-09-02: the complete https://cursor.com/docs/hooks event inventory documents no permission-denied event.", + "state": "unavailable" + }, + "permission/request": { + "reason": "retrieved 2026-09-02: https://cursor.com/docs/hooks documents no standalone permission-request event; pre-tool permission control is beforeShellExecution/beforeMCPExecution and is not inferred as this family per #258.", + "state": "unavailable" + }, "prompt/submit": { "nativeEvent": "beforeSubmitPrompt", "state": "supported" }, "session/end": { "availability": { @@ -44,6 +52,10 @@ }, "session/start": { "nativeEvent": "sessionStart", "state": "supported" }, "stop": { "nativeEvent": "stop", "state": "supported" }, + "stop/failure": { + "reason": "retrieved 2026-09-02: the complete https://cursor.com/docs/hooks event inventory documents no stop-failure event.", + "state": "unavailable" + }, "tool/after": { "nativeEvent": "postToolUse", "state": "supported" }, "tool/before": { "nativeEvent": "preToolUse", "state": "supported" }, "tool/failure": { "nativeEvent": "postToolUseFailure", "state": "supported" }, diff --git a/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json b/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json index f48319031..5d5d901a3 100644 --- a/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json +++ b/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json @@ -20,6 +20,14 @@ "reason": "2026-09-02: Agent Plugins 1.0.0 defines no hooks and therefore no native PreCompact/preCompact equivalent (https://agent-plugins.org/).", "state": "unavailable" }, + "permission/denied": { + "reason": "2026-09-02: Agent Plugins 1.0.0 defines no hooks and therefore no native PermissionDenied equivalent (https://agent-plugins.org/).", + "state": "unavailable" + }, + "permission/request": { + "reason": "2026-09-02: Agent Plugins 1.0.0 defines no hooks and therefore no native PermissionRequest equivalent (https://agent-plugins.org/).", + "state": "unavailable" + }, "prompt/submit": { "reason": "2026-09-02: Agent Plugins 1.0.0 defines no hooks and therefore no native UserPromptSubmit/beforeSubmitPrompt equivalent (https://agent-plugins.org/).", "state": "unavailable" @@ -30,6 +38,10 @@ }, "session/start": { "reason": "Agent Plugins 1.0.0 does not define hooks.", "state": "unavailable" }, "stop": { "reason": "Agent Plugins 1.0.0 does not define hooks.", "state": "unavailable" }, + "stop/failure": { + "reason": "2026-09-02: Agent Plugins 1.0.0 defines no hooks and therefore no native StopFailure equivalent (https://agent-plugins.org/).", + "state": "unavailable" + }, "tool/after": { "reason": "Agent Plugins 1.0.0 does not define hooks.", "state": "unavailable" }, "tool/before": { "reason": "Agent Plugins 1.0.0 does not define hooks.", "state": "unavailable" }, "tool/failure": { diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index 83a08e5a7..25f9693f4 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -225,6 +225,36 @@ export const createNativeEventStarter = ( : {}), stop_hook_active: false, }); + case 'permission/request': + return deepFreeze({ + ...base, + tool_input: toolInput, + tool_name: toolName, + ...(target === 'codex' + ? { + agent_id: 'lifecycle-replay-agent', + agent_type: 'general-purpose', + model: 'default', + permission_mode: 'default', + turn_id: 'lifecycle-replay-turn', + } + : { permission_mode: 'default' }), + }); + case 'permission/denied': + return deepFreeze({ + ...base, + permission_decision: 'deny', + permission_decision_reason: 'Lifecycle replay permission denial.', + tool_input: toolInput, + tool_name: toolName, + }); + case 'stop/failure': + return deepFreeze({ + ...base, + error: 'Lifecycle replay API failure.', + last_assistant_message: null, + stop_hook_active: false, + }); case 'workspace/open': return deepFreeze(target === 'cursor' ? { diff --git a/packages/agent-bundle/src/adapters/schemas/codex/PROVENANCE.json b/packages/agent-bundle/src/adapters/schemas/codex/PROVENANCE.json index 85daf21c1..15be77353 100644 --- a/packages/agent-bundle/src/adapters/schemas/codex/PROVENANCE.json +++ b/packages/agent-bundle/src/adapters/schemas/codex/PROVENANCE.json @@ -2,7 +2,7 @@ "observedCliVersion": "0.147.0", "retrievedAt": "2026-09-02", "schemaSource": "https://github.com/openai/codex/blob/main/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md", - "notes": "plugin.schema.json transcribes the 2026-09-02 documented package manifest rather than a host-exported schema because Codex 0.147.0 publishes no plugin validate command. Skills stays optional so MCP-only plugins admitted by the documented packaging and submission flows validate. The generated subagent-{start,stop}.command.{input,output}.schema.json, user-prompt-submit.command.{input,output}.schema.json, session-end.command.input.schema.json, and {pre,post}-compact.command.{input,output}.schema.json evidence snapshots are byte-pinned from the rust-v0.147.0 tag at https://github.com/openai/codex/tree/rust-v0.147.0/codex-rs/hooks/schema/generated. That tag has no session-end.command.output.schema.json or tool-failure schema. Repository text files add one POSIX trailing newline; adapter-metadata.test.ts removes only that byte before comparing the authoritative upstream SHA-256 digests recorded in codex-0.147.0.json.", + "notes": "plugin.schema.json transcribes the 2026-09-02 documented package manifest rather than a host-exported schema because Codex 0.147.0 publishes no plugin validate command. Skills stays optional so MCP-only plugins admitted by the documented packaging and submission flows validate. The generated subagent-{start,stop}.command.{input,output}.schema.json, user-prompt-submit.command.{input,output}.schema.json, session-end.command.input.schema.json, {pre,post}-compact.command.{input,output}.schema.json, and permission-request.command.{input,output}.schema.json evidence snapshots are byte-pinned from the rust-v0.147.0 tag at https://github.com/openai/codex/tree/rust-v0.147.0/codex-rs/hooks/schema/generated. That tag has no session-end.command.output.schema.json and no tool-failure, permission-denied, or stop-failure schema. Repository text files add one POSIX trailing newline; adapter-metadata.test.ts removes only that byte before comparing the authoritative upstream SHA-256 digests recorded in codex-0.147.0.json.", "schemaTightenings": { "plugin.schema.json": [ "Top-level and author objects are closed; author admits only the documented name, email, and url fields.", diff --git a/packages/agent-bundle/src/adapters/schemas/codex/generated/permission-request.command.input.schema.json b/packages/agent-bundle/src/adapters/schemas/codex/generated/permission-request.command.input.schema.json new file mode 100644 index 000000000..9ee8996db --- /dev/null +++ b/packages/agent-bundle/src/adapters/schemas/codex/generated/permission-request.command.input.schema.json @@ -0,0 +1,67 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "NullableString": { + "type": [ + "string", + "null" + ] + } + }, + "properties": { + "agent_id": { + "type": "string" + }, + "agent_type": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "hook_event_name": { + "const": "PermissionRequest", + "type": "string" + }, + "model": { + "type": "string" + }, + "permission_mode": { + "enum": [ + "default", + "acceptEdits", + "plan", + "dontAsk", + "bypassPermissions" + ], + "type": "string" + }, + "session_id": { + "type": "string" + }, + "tool_input": true, + "tool_name": { + "type": "string" + }, + "transcript_path": { + "$ref": "#/definitions/NullableString" + }, + "turn_id": { + "description": "Codex extension: expose the active turn id to internal turn-scoped hooks.", + "type": "string" + } + }, + "required": [ + "cwd", + "hook_event_name", + "model", + "permission_mode", + "session_id", + "tool_input", + "tool_name", + "transcript_path", + "turn_id" + ], + "title": "permission-request.command.input", + "type": "object" +} \ No newline at end of file diff --git a/packages/agent-bundle/src/adapters/schemas/codex/generated/permission-request.command.output.schema.json b/packages/agent-bundle/src/adapters/schemas/codex/generated/permission-request.command.output.schema.json new file mode 100644 index 000000000..347820f7e --- /dev/null +++ b/packages/agent-bundle/src/adapters/schemas/codex/generated/permission-request.command.output.schema.json @@ -0,0 +1,91 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "PermissionRequestBehaviorWire": { + "enum": [ + "allow", + "deny" + ], + "type": "string" + }, + "PermissionRequestDecisionWire": { + "additionalProperties": false, + "properties": { + "behavior": { + "$ref": "#/definitions/PermissionRequestBehaviorWire" + }, + "interrupt": { + "default": false, + "description": "Reserved for future short-circuiting semantics.\n\nPermissionRequest hooks currently fail closed if this field is `true`.", + "type": "boolean" + }, + "message": { + "default": null, + "type": "string" + }, + "updatedInput": { + "default": null, + "description": "Reserved for a future input-rewrite capability.\n\nPermissionRequest hooks currently fail closed if this field is present." + }, + "updatedPermissions": { + "default": null, + "description": "Reserved for a future permission-rewrite capability.\n\nPermissionRequest hooks currently fail closed if this field is present." + } + }, + "required": [ + "behavior" + ], + "type": "object" + }, + "PermissionRequestHookSpecificOutputWire": { + "additionalProperties": false, + "properties": { + "decision": { + "allOf": [ + { + "$ref": "#/definitions/PermissionRequestDecisionWire" + } + ], + "default": null + }, + "hookEventName": { + "const": "PermissionRequest", + "type": "string" + } + }, + "required": [ + "hookEventName" + ], + "type": "object" + } + }, + "properties": { + "continue": { + "default": true, + "type": "boolean" + }, + "hookSpecificOutput": { + "allOf": [ + { + "$ref": "#/definitions/PermissionRequestHookSpecificOutputWire" + } + ], + "default": null + }, + "stopReason": { + "default": null, + "type": "string" + }, + "suppressOutput": { + "default": false, + "type": "boolean" + }, + "systemMessage": { + "default": null, + "type": "string" + } + }, + "title": "permission-request.command.output", + "type": "object" +} \ No newline at end of file diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index 33ed96727..c78917914 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -113,6 +113,9 @@ const hookEventForRoute: Readonly { + const claudeRequest = { + cwd: '/workspace', + hook_event_name: 'PermissionRequest', + permission_mode: 'default', + session_id: 'session-1', + tool_input: { command: 'rm -rf build' }, + tool_name: 'Bash', + transcript_path: '/workspace/transcript.jsonl', + }; + expect(validateNativeEventEnvelope(claudeRequest, { + canonicalEvent: 'permission/request', + nativeEvent: 'PermissionRequest', + target: 'claude', + })).toBe(claudeRequest); + expect(() => validateNativeEventEnvelope({ ...claudeRequest, permission_mode: 'sometimes' }, { + canonicalEvent: 'permission/request', + nativeEvent: 'PermissionRequest', + target: 'claude', + })).toThrow(/permission_mode/u); + + const codexRequest = { + cwd: '/workspace', + hook_event_name: 'PermissionRequest', + model: 'gpt-5-codex', + permission_mode: 'default', + session_id: 'session-1', + tool_input: { command: 'apply_patch' }, + tool_name: 'apply_patch', + transcript_path: null, + turn_id: 'turn-1', + }; + expect(validateNativeEventEnvelope(codexRequest, { + canonicalEvent: 'permission/request', + nativeEvent: 'PermissionRequest', + target: 'codex', + })).toBe(codexRequest); + expect(() => validateNativeEventEnvelope({ ...codexRequest, turn_id: undefined }, { + canonicalEvent: 'permission/request', + nativeEvent: 'PermissionRequest', + target: 'codex', + })).toThrow(/turn_id/u); + + const claudeDenied = { + cwd: '/workspace', + hook_event_name: 'PermissionDenied', + permission_decision: 'deny', + permission_decision_reason: 'Auto mode denied the command.', + session_id: 'session-1', + tool_input: { command: 'rm -rf build' }, + tool_name: 'Bash', + transcript_path: '/workspace/transcript.jsonl', + }; + expect(validateNativeEventEnvelope(claudeDenied, { + canonicalEvent: 'permission/denied', + nativeEvent: 'PermissionDenied', + target: 'claude', + })).toBe(claudeDenied); + + const claudeStopFailure = { + cwd: '/workspace', + error: 'API Error: 529 overloaded', + hook_event_name: 'StopFailure', + session_id: 'session-1', + stop_hook_active: false, + transcript_path: '/workspace/transcript.jsonl', + }; + expect(validateNativeEventEnvelope(claudeStopFailure, { + canonicalEvent: 'stop/failure', + nativeEvent: 'StopFailure', + target: 'claude', + })).toBe(claudeStopFailure); + expect(() => validateNativeEventEnvelope({ ...claudeStopFailure, error: 42 }, { + canonicalEvent: 'stop/failure', + nativeEvent: 'StopFailure', + target: 'claude', + })).toThrow(/error/u); +}); diff --git a/packages/agent-bundle/tests/fixtures/events/claude-permission-denied.json b/packages/agent-bundle/tests/fixtures/events/claude-permission-denied.json new file mode 100644 index 000000000..1b6e31ed8 --- /dev/null +++ b/packages/agent-bundle/tests/fixtures/events/claude-permission-denied.json @@ -0,0 +1,13 @@ +{ + "cwd": "/workspace", + "hook_event_name": "PermissionDenied", + "permission_decision": "deny", + "permission_decision_reason": "Auto mode denied the destructive command.", + "session_id": "session-claude-1", + "tool_input": { + "command": "rm -rf build", + "description": "Remove the build directory" + }, + "tool_name": "Bash", + "transcript_path": "/workspace/.claude/projects/session.jsonl" +} diff --git a/packages/agent-bundle/tests/fixtures/events/claude-permission-request.json b/packages/agent-bundle/tests/fixtures/events/claude-permission-request.json new file mode 100644 index 000000000..37e7b1d7d --- /dev/null +++ b/packages/agent-bundle/tests/fixtures/events/claude-permission-request.json @@ -0,0 +1,12 @@ +{ + "cwd": "/workspace", + "hook_event_name": "PermissionRequest", + "permission_mode": "default", + "session_id": "session-claude-1", + "tool_input": { + "command": "rm -rf build", + "description": "Remove the build directory" + }, + "tool_name": "Bash", + "transcript_path": "/workspace/.claude/projects/session.jsonl" +} diff --git a/packages/agent-bundle/tests/fixtures/events/claude-stop-failure.json b/packages/agent-bundle/tests/fixtures/events/claude-stop-failure.json new file mode 100644 index 000000000..d5de58913 --- /dev/null +++ b/packages/agent-bundle/tests/fixtures/events/claude-stop-failure.json @@ -0,0 +1,9 @@ +{ + "cwd": "/workspace", + "error": "API Error: 529 overloaded", + "hook_event_name": "StopFailure", + "last_assistant_message": null, + "session_id": "session-claude-1", + "stop_hook_active": false, + "transcript_path": "/workspace/.claude/projects/session.jsonl" +} diff --git a/packages/agent-bundle/tests/fixtures/events/codex-permission-request.json b/packages/agent-bundle/tests/fixtures/events/codex-permission-request.json new file mode 100644 index 000000000..9e9c4e071 --- /dev/null +++ b/packages/agent-bundle/tests/fixtures/events/codex-permission-request.json @@ -0,0 +1,13 @@ +{ + "cwd": "/workspace", + "hook_event_name": "PermissionRequest", + "model": "gpt-5-codex", + "permission_mode": "default", + "session_id": "session-codex-1", + "tool_input": { + "command": "*** Begin Patch\n*** Delete File: build/manifest.json\n*** End Patch" + }, + "tool_name": "apply_patch", + "transcript_path": null, + "turn_id": "turn-codex-1" +} 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 d1c5f2642..2dec28d03 100644 --- a/packages/agent-bundle/tests/route-unit/event-project.test.ts +++ b/packages/agent-bundle/tests/route-unit/event-project.test.ts @@ -454,3 +454,145 @@ it('projects compact/after as observation-only on supported hosts', async () => .toThrow(/compact\/after is observation-only/u); } }); + +it('projects permission/request decisions through the pinned PermissionRequest output contract', async () => { + const props = createCanonicalEventProps( + 'permission/request', + { + cwd: '/workspace', + hook_event_name: 'PermissionRequest', + permission_mode: 'default', + session_id: 'session-1', + tool_input: { command: 'rm -rf build' }, + tool_name: 'Bash', + transcript_path: '/workspace/transcript.jsonl', + }, + 'claude', + 'PermissionRequest', + '2.1.250', + new AbortController().signal, + ); + const routeInput = { + input: { canonical: props.canonical, native: props.native }, + kind: 'event-route', + routeId: 'event:permission/request', + } as const; + + const denied = await renderRoute({ + default: async () => createElement( + Agent.Result, + { value: { outcome: 'deny', reason: 'Destructive command requires review.' } }, + ), + }, routeInput); + for (const target of ['claude', 'codex']) { + expect(projectEventDocument(denied.document, 'permission/request', target, 'PermissionRequest')).toEqual({ + hookSpecificOutput: { + decision: { + behavior: 'deny', + message: 'Destructive command requires review.', + }, + hookEventName: 'PermissionRequest', + }, + }); + } + + const allowed = await renderRoute({ + default: async () => createElement(Agent.Result, { value: { outcome: 'continue' } }), + }, routeInput); + expect(projectEventDocument(allowed.document, 'permission/request', 'claude', 'PermissionRequest')).toEqual({ + hookSpecificOutput: { + decision: { behavior: 'allow' }, + hookEventName: 'PermissionRequest', + }, + }); + + const observed = await renderRoute({ + default: async () => createElement(Agent.Result), + }, routeInput); + expect(projectEventDocument(observed.document, 'permission/request', 'codex', 'PermissionRequest')).toBeUndefined(); + + const rewritten = await renderRoute({ + default: async () => createElement(Agent.Result, { value: { updatedInput: { command: 'rm -r build' } } }), + }, routeInput); + expect(() => projectEventDocument(rewritten.document, 'permission/request', 'claude', 'PermissionRequest')) + .toThrow(/input rewrite is reserved upstream and fails closed/u); + + const contextual = await renderRoute({ + default: async () => createElement( + Agent.Result, + null, + createElement(Agent.Context, null, 'Context is not part of this contract.'), + ), + }, routeInput); + expect(() => projectEventDocument(contextual.document, 'permission/request', 'claude', 'PermissionRequest')) + .toThrow(/no additional-context channel/u); +}); + +it('projects permission/denied and stop/failure as observation-only Claude families', async () => { + const deniedProps = createCanonicalEventProps( + 'permission/denied', + { + cwd: '/workspace', + hook_event_name: 'PermissionDenied', + permission_decision: 'deny', + permission_decision_reason: 'Auto mode denied the command.', + session_id: 'session-1', + tool_input: { command: 'rm -rf build' }, + tool_name: 'Bash', + transcript_path: '/workspace/transcript.jsonl', + }, + 'claude', + 'PermissionDenied', + '2.1.250', + new AbortController().signal, + ); + const deniedInput = { + input: { canonical: deniedProps.canonical, native: deniedProps.native }, + kind: 'event-route', + routeId: 'event:permission/denied', + } as const; + const deniedObserved = await renderRoute({ + default: async () => createElement(Agent.Result), + }, deniedInput); + expect(projectEventDocument(deniedObserved.document, 'permission/denied', 'claude', 'PermissionDenied')).toBeUndefined(); + const deniedRejected = await renderRoute({ + default: async () => createElement(Agent.Result, { value: { outcome: 'deny', reason: 'Again.' } }), + }, deniedInput); + expect(() => projectEventDocument(deniedRejected.document, 'permission/denied', 'claude', 'PermissionDenied')) + .toThrow(/observes an already-denied call/u); + const deniedContextual = await renderRoute({ + default: async () => createElement(Agent.Result, null, createElement(Agent.Context, null, 'Retry hint.')), + }, deniedInput); + expect(() => projectEventDocument(deniedContextual.document, 'permission/denied', 'claude', 'PermissionDenied')) + .toThrow(/retry signalling has no canonical vocabulary yet/u); + + const failureProps = createCanonicalEventProps( + 'stop/failure', + { + cwd: '/workspace', + error: 'API Error: 529 overloaded', + hook_event_name: 'StopFailure', + session_id: 'session-1', + stop_hook_active: false, + transcript_path: '/workspace/transcript.jsonl', + }, + 'claude', + 'StopFailure', + '2.1.250', + new AbortController().signal, + ); + const failureInput = { + input: { canonical: failureProps.canonical, native: failureProps.native }, + kind: 'event-route', + routeId: 'event:stop/failure', + } as const; + const failureObserved = await renderRoute({ + default: async () => createElement(Agent.Result), + }, failureInput); + expect(projectEventDocument(failureObserved.document, 'stop/failure', 'claude', 'StopFailure')).toBeUndefined(); + const failureRejected = await renderRoute({ + default: async () => createElement(Agent.Result, { value: { outcome: 'deny', reason: 'Do not end.' } }), + }, failureInput); + expect(() => projectEventDocument(failureRejected.document, 'stop/failure', 'claude', 'StopFailure')) + .toThrow(/observes an API-error turn end/u); +});