@webpieces/ai-hook-rules 0.4.703 → 0.4.705

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/package.json +4 -2
  2. package/src/adapters/agent-adapters.d.ts +13 -0
  3. package/src/adapters/agent-adapters.js +23 -0
  4. package/src/adapters/agent-adapters.js.map +1 -0
  5. package/src/adapters/agent-payload.d.ts +48 -0
  6. package/src/adapters/agent-payload.js +30 -0
  7. package/src/adapters/agent-payload.js.map +1 -0
  8. package/src/adapters/agent-response.d.ts +6 -0
  9. package/src/adapters/{claude-code-response.js → agent-response.js} +56 -22
  10. package/src/adapters/agent-response.js.map +1 -0
  11. package/src/adapters/claude-code-adapter.d.ts +26 -0
  12. package/src/adapters/claude-code-adapter.js +69 -0
  13. package/src/adapters/claude-code-adapter.js.map +1 -0
  14. package/src/adapters/codex-adapter.d.ts +19 -0
  15. package/src/adapters/codex-adapter.js +48 -0
  16. package/src/adapters/codex-adapter.js.map +1 -0
  17. package/src/adapters/codex-subagent-guard.d.ts +30 -0
  18. package/src/adapters/codex-subagent-guard.js +58 -0
  19. package/src/adapters/codex-subagent-guard.js.map +1 -0
  20. package/src/adapters/detect-ai.d.ts +36 -0
  21. package/src/adapters/detect-ai.js +47 -0
  22. package/src/adapters/detect-ai.js.map +1 -0
  23. package/src/adapters/guards-hook.d.ts +1 -0
  24. package/src/adapters/guards-hook.js +21 -6
  25. package/src/adapters/guards-hook.js.map +1 -1
  26. package/src/adapters/hook-app-fixtures.d.ts +68 -0
  27. package/src/adapters/hook-app-fixtures.js +175 -0
  28. package/src/adapters/hook-app-fixtures.js.map +1 -0
  29. package/src/adapters/hook-app.d.ts +61 -0
  30. package/src/adapters/hook-app.js +110 -0
  31. package/src/adapters/hook-app.js.map +1 -0
  32. package/src/adapters/hook-core.d.ts +15 -6
  33. package/src/adapters/hook-core.js +157 -139
  34. package/src/adapters/hook-core.js.map +1 -1
  35. package/src/adapters/hook-outcome.d.ts +46 -0
  36. package/src/adapters/hook-outcome.js +60 -0
  37. package/src/adapters/hook-outcome.js.map +1 -0
  38. package/src/adapters/hook-ports.d.ts +57 -0
  39. package/src/adapters/hook-ports.js +90 -0
  40. package/src/adapters/hook-ports.js.map +1 -0
  41. package/src/adapters/rules-hook.d.ts +1 -0
  42. package/src/adapters/rules-hook.js +19 -5
  43. package/src/adapters/rules-hook.js.map +1 -1
  44. package/src/core/agent-event.d.ts +65 -0
  45. package/src/core/agent-event.js +59 -0
  46. package/src/core/agent-event.js.map +1 -0
  47. package/src/core/apply-patch-parse.d.ts +36 -0
  48. package/src/core/apply-patch-parse.js +154 -0
  49. package/src/core/apply-patch-parse.js.map +1 -0
  50. package/src/core/delete-scoped-rules.d.ts +8 -0
  51. package/src/core/delete-scoped-rules.js +31 -0
  52. package/src/core/delete-scoped-rules.js.map +1 -0
  53. package/src/core/runner.js +2 -1
  54. package/src/core/runner.js.map +1 -1
  55. package/src/core/shell-read-parity.d.ts +22 -0
  56. package/src/core/shell-read-parity.js +145 -0
  57. package/src/core/shell-read-parity.js.map +1 -0
  58. package/src/core/types.d.ts +1 -1
  59. package/src/core/types.js.map +1 -1
  60. package/src/index.d.ts +2 -0
  61. package/src/index.js +11 -1
  62. package/src/index.js.map +1 -1
  63. package/src/adapters/claude-code-response.d.ts +0 -3
  64. package/src/adapters/claude-code-response.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webpieces/ai-hook-rules",
3
- "version": "0.4.703",
3
+ "version": "0.4.705",
4
4
  "description": "Pluggable write-time validation framework for AI coding agents (@webpieces/ai-hook-rules). Claude Code PreToolUse + openclaw before_tool_call adapters share one rule engine.",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
@@ -25,7 +25,9 @@
25
25
  "directory": "packages/tooling/ai-hook-rules"
26
26
  },
27
27
  "dependencies": {
28
- "@webpieces/rules-config": "0.4.703"
28
+ "@webpieces/rules-config": "0.4.705",
29
+ "inversify": "7.10.4",
30
+ "reflect-metadata": "0.2.2"
29
31
  },
30
32
  "publishConfig": {
31
33
  "access": "public"
@@ -0,0 +1,13 @@
1
+ import { AgentPayload } from './agent-payload';
2
+ import { AgentHookEvent } from '../core/agent-event';
3
+ /**
4
+ * The ONE place a payload is routed to its harness's adapter. Every other module takes an
5
+ * `AgentHookEvent` and never asks which agent produced it — except the two codex-only surfaces
6
+ * (read parity, the shared-tree subagent guard), which check `aiType` explicitly and say so.
7
+ */
8
+ export declare class AgentAdapters {
9
+ private readonly claude;
10
+ private readonly codex;
11
+ envelope(payload: AgentPayload): AgentHookEvent;
12
+ toEvent(payload: AgentPayload, cwd: string): AgentHookEvent;
13
+ }
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AgentAdapters = void 0;
4
+ const detect_ai_1 = require("./detect-ai");
5
+ const claude_code_adapter_1 = require("./claude-code-adapter");
6
+ const codex_adapter_1 = require("./codex-adapter");
7
+ /**
8
+ * The ONE place a payload is routed to its harness's adapter. Every other module takes an
9
+ * `AgentHookEvent` and never asks which agent produced it — except the two codex-only surfaces
10
+ * (read parity, the shared-tree subagent guard), which check `aiType` explicitly and say so.
11
+ */
12
+ class AgentAdapters {
13
+ claude = new claude_code_adapter_1.ClaudeCodeAdapter();
14
+ codex = new codex_adapter_1.CodexAdapter();
15
+ envelope(payload) {
16
+ return (0, detect_ai_1.detectAiType)(payload) === 'codex' ? this.codex.envelope(payload) : this.claude.envelope(payload);
17
+ }
18
+ toEvent(payload, cwd) {
19
+ return (0, detect_ai_1.detectAiType)(payload) === 'codex' ? this.codex.toEvent(payload, cwd) : this.claude.toEvent(payload, cwd);
20
+ }
21
+ }
22
+ exports.AgentAdapters = AgentAdapters;
23
+ //# sourceMappingURL=agent-adapters.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent-adapters.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/agent-adapters.ts"],"names":[],"mappings":";;;AAEA,2CAA2C;AAC3C,+DAA0D;AAC1D,mDAA+C;AAE/C;;;;GAIG;AACH,MAAa,aAAa;IACL,MAAM,GAAG,IAAI,uCAAiB,EAAE,CAAC;IACjC,KAAK,GAAG,IAAI,4BAAY,EAAE,CAAC;IAE5C,QAAQ,CAAC,OAAqB;QAC1B,OAAO,IAAA,wBAAY,EAAC,OAAO,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;IAC5G,CAAC;IAED,OAAO,CAAC,OAAqB,EAAE,GAAW;QACtC,OAAO,IAAA,wBAAY,EAAC,OAAO,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,OAAO,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,GAAG,CAAC,CAAC;IACpH,CAAC;CACJ;AAXD,sCAWC","sourcesContent":["import { AgentPayload } from './agent-payload';\nimport { AgentHookEvent } from '../core/agent-event';\nimport { detectAiType } from './detect-ai';\nimport { ClaudeCodeAdapter } from './claude-code-adapter';\nimport { CodexAdapter } from './codex-adapter';\n\n/**\n * The ONE place a payload is routed to its harness's adapter. Every other module takes an\n * `AgentHookEvent` and never asks which agent produced it — except the two codex-only surfaces\n * (read parity, the shared-tree subagent guard), which check `aiType` explicitly and say so.\n */\nexport class AgentAdapters {\n private readonly claude = new ClaudeCodeAdapter();\n private readonly codex = new CodexAdapter();\n\n envelope(payload: AgentPayload): AgentHookEvent {\n return detectAiType(payload) === 'codex' ? this.codex.envelope(payload) : this.claude.envelope(payload);\n }\n\n toEvent(payload: AgentPayload, cwd: string): AgentHookEvent {\n return detectAiType(payload) === 'codex' ? this.codex.toEvent(payload, cwd) : this.claude.toEvent(payload, cwd);\n }\n}\n"]}
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The RAW PreToolUse wire envelope, as it arrives on stdin.
3
+ *
4
+ * ONE shape for both harnesses, because that is what was measured: Codex uses the SAME key names as
5
+ * Claude Code (`hook_event_name`, `tool_name`, `tool_input`, `cwd`, `session_id`, `transcript_path`,
6
+ * `agent_id`, `agent_type`) and merely ADDS `model`, `turn_id` and `tool_use_id`. A second payload
7
+ * type for the second harness would be two spellings of one thing.
8
+ *
9
+ * Interfaces, not classes, and deliberately so: nothing in this codebase ever CONSTRUCTS one of these.
10
+ * They describe bytes somebody else wrote, which `JSON.parse` hands back as a plain object — the same
11
+ * reason the shape this replaces was an interface.
12
+ */
13
+ export interface AgentPayload {
14
+ tool_name: string;
15
+ tool_input: AgentToolInput;
16
+ /** The session's current working directory. Used to scope guards to the tree the agent is in. */
17
+ cwd?: string;
18
+ session_id?: string;
19
+ /** Empty/absent ⇒ the coordinator, populated ⇒ a subagent. MEASURED identical in both harnesses. */
20
+ agent_id?: string;
21
+ agent_type?: string;
22
+ /** Codex-only, and REQUIRED there. The one discriminator — see ./detect-ai.ts. */
23
+ turn_id?: string;
24
+ }
25
+ export interface AgentToolInput {
26
+ file_path?: string;
27
+ content?: string;
28
+ old_string?: string;
29
+ new_string?: string;
30
+ edits?: AgentEditEntry[];
31
+ command?: string;
32
+ }
33
+ export interface AgentEditEntry {
34
+ old_string?: string;
35
+ new_string?: string;
36
+ }
37
+ export declare class AgentPayloadParser {
38
+ /**
39
+ * Returns null for empty stdin (nothing to judge); throws InformAiError on unparseable bytes.
40
+ *
41
+ * The message still names Claude Code even though this path is shared, and that is DELIBERATE and
42
+ * temporary: the overriding constraint on this change is that no Claude Code behaviour moves, and a
43
+ * user-visible string is behaviour. Renaming it is a one-line follow-up once the Codex path is
44
+ * actually armed (it cannot mislead anyone before then — no Codex session reaches this code until
45
+ * the installer lands).
46
+ */
47
+ parse(raw: string): AgentPayload | null;
48
+ }
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AgentPayloadParser = void 0;
4
+ const types_1 = require("../core/types");
5
+ const to_error_1 = require("../core/to-error");
6
+ class AgentPayloadParser {
7
+ /**
8
+ * Returns null for empty stdin (nothing to judge); throws InformAiError on unparseable bytes.
9
+ *
10
+ * The message still names Claude Code even though this path is shared, and that is DELIBERATE and
11
+ * temporary: the overriding constraint on this change is that no Claude Code behaviour moves, and a
12
+ * user-visible string is behaviour. Renaming it is a one-line follow-up once the Codex path is
13
+ * actually armed (it cannot mislead anyone before then — no Codex session reaches this code until
14
+ * the installer lands).
15
+ */
16
+ parse(raw) {
17
+ if (!raw || raw.trim() === '')
18
+ return null;
19
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
20
+ try {
21
+ return JSON.parse(raw);
22
+ }
23
+ catch (err) {
24
+ const error = (0, to_error_1.toError)(err);
25
+ throw new types_1.InformAiError(`Malformed hook input from Claude Code stdin: ${error.message}`, { cause: error });
26
+ }
27
+ }
28
+ }
29
+ exports.AgentPayloadParser = AgentPayloadParser;
30
+ //# sourceMappingURL=agent-payload.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent-payload.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/agent-payload.ts"],"names":[],"mappings":";;;AAAA,yCAA8C;AAC9C,+CAA2C;AAyC3C,MAAa,kBAAkB;IAC3B;;;;;;;;OAQG;IACH,KAAK,CAAC,GAAW;QACb,IAAI,CAAC,GAAG,IAAI,GAAG,CAAC,IAAI,EAAE,KAAK,EAAE;YAAE,OAAO,IAAI,CAAC;QAC3C,8DAA8D;QAC9D,IAAI,CAAC;YACD,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAiB,CAAC;QAC3C,CAAC;QAAC,OAAO,GAAY,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;YAC3B,MAAM,IAAI,qBAAa,CAAC,gDAAgD,KAAK,CAAC,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC;QAC/G,CAAC;IACL,CAAC;CACJ;AApBD,gDAoBC","sourcesContent":["import { InformAiError } from '../core/types';\nimport { toError } from '../core/to-error';\n\n/**\n * The RAW PreToolUse wire envelope, as it arrives on stdin.\n *\n * ONE shape for both harnesses, because that is what was measured: Codex uses the SAME key names as\n * Claude Code (`hook_event_name`, `tool_name`, `tool_input`, `cwd`, `session_id`, `transcript_path`,\n * `agent_id`, `agent_type`) and merely ADDS `model`, `turn_id` and `tool_use_id`. A second payload\n * type for the second harness would be two spellings of one thing.\n *\n * Interfaces, not classes, and deliberately so: nothing in this codebase ever CONSTRUCTS one of these.\n * They describe bytes somebody else wrote, which `JSON.parse` hands back as a plain object — the same\n * reason the shape this replaces was an interface.\n */\nexport interface AgentPayload {\n tool_name: string;\n tool_input: AgentToolInput;\n /** The session's current working directory. Used to scope guards to the tree the agent is in. */\n cwd?: string;\n session_id?: string;\n /** Empty/absent ⇒ the coordinator, populated ⇒ a subagent. MEASURED identical in both harnesses. */\n agent_id?: string;\n agent_type?: string;\n /** Codex-only, and REQUIRED there. The one discriminator — see ./detect-ai.ts. */\n turn_id?: string;\n}\n\nexport interface AgentToolInput {\n file_path?: string;\n content?: string;\n old_string?: string;\n new_string?: string;\n edits?: AgentEditEntry[];\n command?: string;\n}\n\nexport interface AgentEditEntry {\n old_string?: string;\n new_string?: string;\n}\n\nexport class AgentPayloadParser {\n /**\n * Returns null for empty stdin (nothing to judge); throws InformAiError on unparseable bytes.\n *\n * The message still names Claude Code even though this path is shared, and that is DELIBERATE and\n * temporary: the overriding constraint on this change is that no Claude Code behaviour moves, and a\n * user-visible string is behaviour. Renaming it is a one-line follow-up once the Codex path is\n * actually armed (it cannot mislead anyone before then — no Codex session reaches this code until\n * the installer lands).\n */\n parse(raw: string): AgentPayload | null {\n if (!raw || raw.trim() === '') return null;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return JSON.parse(raw) as AgentPayload;\n } catch (err: unknown) {\n const error = toError(err);\n throw new InformAiError(`Malformed hook input from Claude Code stdin: ${error.message}`, { cause: error });\n }\n }\n}\n"]}
@@ -0,0 +1,6 @@
1
+ import { AgentHookEvent } from '../core/agent-event';
2
+ import { HookOutcome } from './hook-outcome';
3
+ export declare function denyJson(event: AgentHookEvent | null, reason: string): string;
4
+ export declare function denyOutcome(event: AgentHookEvent | null, reason: string, rule?: string, fault?: string): HookOutcome;
5
+ export declare function emitDeny(event: AgentHookEvent | null, reason: string, rule?: string, fault?: string): never;
6
+ export declare function emitAllow(): never;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
- // The single place that knows Claude Code's PreToolUse decision protocol, so every deny in the
3
- // Claude Code adapter is emitted identically — and identically to the checked-in shim
2
+ // The single place that knows the PreToolUse decision protocol, so every deny is emitted identically
3
+ // — and identically to the checked-in shim
4
4
  // (.claude/webpieces/ai-hook.sh, rendered by renderShim() in ../bin/shim.ts), which emits the same JSON.
5
5
  //
6
6
  // A block is signalled by `permissionDecision: "deny"` JSON on STDOUT with exit 0 — NOT exit 2.
@@ -28,10 +28,12 @@
28
28
  // Refs: Claude Code GitHub issues #31592, #40380, #17356 (asymmetry "closed / not planned").
29
29
  Object.defineProperty(exports, "__esModule", { value: true });
30
30
  exports.denyJson = denyJson;
31
+ exports.denyOutcome = denyOutcome;
31
32
  exports.emitDeny = emitDeny;
32
33
  exports.emitAllow = emitAllow;
33
34
  const decision_log_1 = require("../core/decision-log");
34
35
  const l0_fault_codes_1 = require("../core/l0-fault-codes");
36
+ const hook_outcome_1 = require("./hook-outcome");
35
37
  // ANSI escape (0x1b) built at runtime so no raw ESC byte sits in source. ANSI red is a *bonus* — the
36
38
  // 🛑 prefix + reason stay meaningful if a future/CI renderer strips the color. One place = one escape.
37
39
  const ESC = String.fromCharCode(0x1b);
@@ -47,52 +49,84 @@ const ESC = String.fromCharCode(0x1b);
47
49
  * The reset (`[0m`) still closes the sequence on the same line it opened, so nothing leaks into the
48
50
  * body or into whatever the terminal prints next.
49
51
  */
52
+ // webpieces-disable no-function-outside-class -- private helper of the PreToolUse protocol boundary below; this module must stay callable from a tree too broken to build a DI container
50
53
  function redSystemMessage(reason) {
51
54
  const nl = reason.indexOf('\n');
52
55
  if (nl < 0)
53
56
  return `${ESC}[31;1m🛑 ${reason}${ESC}[0m`;
54
57
  return `${ESC}[31;1m🛑 ${reason.slice(0, nl)}${ESC}[0m${reason.slice(nl)}`;
55
58
  }
56
- function denyJson(reason, toolName) {
59
+ // Takes the EVENT rather than a tool-name string, because the one thing this decision needs is the
60
+ // event's routing kind, and the harnesses spell their tool names differently (`Bash` vs `apply_patch`)
61
+ // while agreeing on the kind. The emitted bytes are identical for both harnesses — Codex accepts the
62
+ // same `permissionDecision: "deny"` + `permissionDecisionReason` + `systemMessage` fields, and rejects
63
+ // nothing we emit. It does hard-reject an EMPTY `permissionDecisionReason` where Claude tolerates one,
64
+ // which is why emitDeny below refuses to send one.
65
+ // webpieces-disable no-function-outside-class -- the PreToolUse wire shape itself, module-scope beside emitDeny/emitAllow by design, and it must stay callable from a tree too broken to build a DI container
66
+ function denyJson(event, reason) {
67
+ // NEVER an empty reason, and the check lives HERE because this function owns the wire shape. Codex
68
+ // hard-rejects a deny whose permissionDecisionReason is empty (Claude tolerates it and shows the
69
+ // human nothing), so an empty one is not a cosmetic defect — it is a block that silently fails to
70
+ // block. Every call site passes prose; this is the backstop that keeps a future one from turning a
71
+ // deny into a protocol error.
72
+ const safe = reason.trim() === '' ? '[ai-hooks] blocked, but the guard produced no reason — failing closed.' : reason;
57
73
  const hookSpecificOutput = {
58
74
  hookEventName: 'PreToolUse',
59
75
  permissionDecision: 'deny',
60
- permissionDecisionReason: reason,
76
+ permissionDecisionReason: safe,
61
77
  };
62
78
  // Bash only: permissionDecisionReason is NOT user-visible, so add the red systemMessage.
63
- if (toolName === 'Bash') {
64
- return JSON.stringify({ systemMessage: redSystemMessage(reason), hookSpecificOutput });
79
+ if (event !== null && event.kind === 'Bash') {
80
+ return JSON.stringify({ systemMessage: redSystemMessage(safe), hookSpecificOutput });
65
81
  }
66
82
  // Write/Edit/MultiEdit (and anything else): reason renders red natively; no systemMessage.
67
83
  return JSON.stringify({ hookSpecificOutput });
68
84
  }
69
- // Block the tool call and surface `reason` to both the user (terminal UI) and the model. `toolName`
70
- // selects whether the red `systemMessage` is added (Bash) or omitted (file tools) — see denyJson.
71
- // emitDeny/emitAllow are the hook's designated terminal boundary — the exit code IS the Claude Code
72
- // PreToolUse protocol (exit 0 + JSON = the contract), so the process.exit stays and is allowlisted.
85
+ // THE DENY, AS A VALUE. `reason` is surfaced to both the user (terminal UI) and the model; the event's
86
+ // kind selects whether the red `systemMessage` is added (Bash) or omitted (file tools) — see denyJson.
73
87
  //
74
- // Being the ONE boundary every path exits through is also why the per-invocation audit line is
75
- // flushed HERE: the `calls/` stream carries the outcome of its own call, and the outcome is not
88
+ // This is the ONE boundary every blocking path passes through, which is why the per-invocation audit
89
+ // line is flushed HERE: the `calls/` stream carries the outcome of its own call, and the outcome is not
76
90
  // known until this point. `rule` names what blocked (or '-'), for the line's `rule=` field.
77
91
  //
78
92
  // `fault` is the L0 fault code when the block IS an L0 fault (S/C/Y — the three decided here in JS,
79
93
  // where the sh shim's own `fault=` stamp can never reach), else '-'. Stamping it at this ONE boundary is
80
94
  // what makes `grep 'fault=S'` span the whole audit trail rather than only its sh half.
81
- // webpieces-disable no-function-outside-class -- the Claude Code PreToolUse protocol boundary; module-scope beside denyJson/emitAllow by design, and it must stay callable from a tree too broken to build a DI container.
82
- function emitDeny(reason, toolName, rule = '-', fault = l0_fault_codes_1.L0_FAULT_NONE) {
95
+ //
96
+ // It RETURNS the outcome instead of writing it. The write and the exit belong to HookApp, which owns
97
+ // the injected stdout/exit ports — that separation is what lets a golden test read the exact bytes a
98
+ // composed run produces. `denyForCrash` (hook-core) needs the value rather than the throw, because it
99
+ // is already INSIDE the catch that would swallow one.
100
+ // webpieces-disable no-function-outside-class -- the Claude Code PreToolUse protocol boundary; module-scope beside denyJson/allowOutcome by design, and it must stay callable from a tree too broken to build a DI container.
101
+ function denyOutcome(event, reason, rule = '-', fault = l0_fault_codes_1.L0_FAULT_NONE) {
83
102
  // BLOCK_AI_CURE: every deny that reaches this boundary prints a cure the agent can act on — the
84
103
  // L0 faults name a command on the allowlist, and the L1/L2 guards print theirs. A deny needing a
85
104
  // HUMAN would have to say so at its own site; none does today, and inventing one here would be
86
105
  // guessing at the boundary rather than at the decision.
87
106
  decision_log_1.invocationLog.finish('BLOCK_AI_CURE', rule, fault);
88
- process.stdout.write(denyJson(reason, toolName) + '\n');
89
- // webpieces-disable no-process-exit-outside-main -- hook exit-code IS the Claude Code PreToolUse protocol (exit 0 + JSON = the contract); designated terminal boundary.
90
- process.exit(0);
107
+ return new hook_outcome_1.HookOutcome(denyJson(event, reason) + '\n', 0);
91
108
  }
92
- // Allow the tool call. No JSON needed — a silent exit 0 is "allow" in the PreToolUse protocol.
93
- function emitAllow() {
109
+ // The ALLOW, as a value. No JSON — a silent exit 0 is "allow" in the PreToolUse protocol.
110
+ //
111
+ // NOT exported, deliberately, where denyOutcome is: `denyForCrash` genuinely needs the deny in value
112
+ // form because it is already inside the catch a throw would land in, and nothing needs the allow that
113
+ // way. An exported one would be a second, externally-pickable spelling of "allow" sitting three lines
114
+ // from `emitAllow` — precisely the shape an agent picks by accident.
115
+ // webpieces-disable no-function-outside-class -- the PreToolUse protocol boundary, module-scope beside denyJson/denyOutcome by design, and it must stay callable from a tree too broken to build a DI container
116
+ function allowOutcome() {
94
117
  decision_log_1.invocationLog.finish('ALLOW', '-');
95
- // webpieces-disable no-process-exit-outside-main -- hook exit-code IS the Claude Code PreToolUse protocol (silent exit 0 = "allow"); designated terminal boundary.
96
- process.exit(0);
118
+ return new hook_outcome_1.HookOutcome('', 0);
119
+ }
120
+ // Block the tool call and END the invocation from wherever in the pipeline we are — see HookTerminated
121
+ // for why the terminal control flow is a throw now rather than a `process.exit` at the call site. Still
122
+ // typed `never`: nothing after a call to this runs.
123
+ // webpieces-disable no-function-outside-class -- the Claude Code PreToolUse protocol boundary; module-scope beside denyJson/emitAllow by design, and it must stay callable from a tree too broken to build a DI container.
124
+ function emitDeny(event, reason, rule = '-', fault = l0_fault_codes_1.L0_FAULT_NONE) {
125
+ throw new hook_outcome_1.HookTerminated(denyOutcome(event, reason, rule, fault));
126
+ }
127
+ // Allow the tool call and END the invocation. See emitDeny.
128
+ // webpieces-disable no-function-outside-class -- the PreToolUse protocol boundary, module-scope beside denyJson/emitDeny by design, and it must stay callable from a tree too broken to build a DI container
129
+ function emitAllow() {
130
+ throw new hook_outcome_1.HookTerminated(allowOutcome());
97
131
  }
98
- //# sourceMappingURL=claude-code-response.js.map
132
+ //# sourceMappingURL=agent-response.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent-response.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/agent-response.ts"],"names":[],"mappings":"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","sourcesContent":["// The single place that knows the PreToolUse decision protocol, so every deny is emitted identically\n// — and identically to the checked-in shim\n// (.claude/webpieces/ai-hook.sh, rendered by renderShim() in ../bin/shim.ts), which emits the same JSON.\n//\n// A block is signalled by `permissionDecision: \"deny\"` JSON on STDOUT with exit 0 — NOT exit 2.\n// Claude Code only parses the JSON on exit 0; exit 2 would ignore stdout and the reason would not\n// surface in the terminal UI. \"deny\" still blocks the tool, so this remains fail-closed: it is not the\n// silent-allow a bare exit 0 with no JSON would be.\n//\n// WHY the tool-conditional `systemMessage` (verified by live tests against Claude Code v2.1.x — the\n// docs are wrong here; do NOT re-derive from them):\n//\n// | deny field | Bash tool | Write/Edit/MultiEdit tool |\n// |-----------------------------------|-----------------------------------|-------------------------------|\n// | permissionDecisionReason (plain) | model sees it; USER SEES NOTHING | model + RED \"Error:\" block ok |\n// | systemMessage | ONLY user-visible field; grey | grey extra line (redundant) |\n// | systemMessage wrapped in ANSI red | RED + visible to the user (fix) | redundant 2nd red line |\n//\n// So: on a **Bash** deny we ALSO emit a top-level `systemMessage` wrapped in ANSI red (ESC[31;1m …\n// ESC[0m) — it is the only field a Bash deny shows the human, and it honors ANSI. On\n// Write/Edit/MultiEdit we add NO `systemMessage` (the reason already renders red natively — a second\n// line is just noise). `permissionDecisionReason` is always plain text (never ANSI): it's what the\n// model reads and what Write/Edit renders red. JSON.stringify serializes the ESC char as the valid\n// \\u escape, so the payload stays valid JSON — we build the ESC via String.fromCharCode(0x1b) so no\n// raw ESC (0x1b) byte ever lives in this source file. Do NOT use exit 2 (stdout JSON ignored;\n// stderr invisible to the user on Bash).\n// Refs: Claude Code GitHub issues #31592, #40380, #17356 (asymmetry \"closed / not planned\").\n\nimport { invocationLog } from '../core/decision-log';\nimport { L0_FAULT_NONE } from '../core/l0-fault-codes';\nimport { AgentHookEvent } from '../core/agent-event';\nimport { HookOutcome, HookTerminated } from './hook-outcome';\n\n// ANSI escape (0x1b) built at runtime so no raw ESC byte sits in source. ANSI red is a *bonus* — the\n// 🛑 prefix + reason stay meaningful if a future/CI renderer strips the color. One place = one escape.\nconst ESC = String.fromCharCode(0x1b);\n\n/**\n * ONLY THE HEADLINE IS RED. The body is left plain, and that is a legibility decision, not an oversight.\n *\n * Every deny that reaches here is MULTI-LINE — formatReport()'s `[rule] (N violations)` / `→ why` /\n * `Fix Option N:` skeleton for L1 and L2, and now the same skeleton for L0. A whole page rendered in\n * bold red is harder to read than the paragraph it replaced: the indentation that carries the structure\n * stops registering when every line shouts. Red the first line so the block is unmissable in a scroll of\n * terminal output, then let the structure do the rest of the work.\n *\n * The reset (`[0m`) still closes the sequence on the same line it opened, so nothing leaks into the\n * body or into whatever the terminal prints next.\n */\n// webpieces-disable no-function-outside-class -- private helper of the PreToolUse protocol boundary below; this module must stay callable from a tree too broken to build a DI container\nfunction redSystemMessage(reason: string): string {\n const nl = reason.indexOf('\\n');\n if (nl < 0) return `${ESC}[31;1m🛑 ${reason}${ESC}[0m`;\n return `${ESC}[31;1m🛑 ${reason.slice(0, nl)}${ESC}[0m${reason.slice(nl)}`;\n}\n\n// Takes the EVENT rather than a tool-name string, because the one thing this decision needs is the\n// event's routing kind, and the harnesses spell their tool names differently (`Bash` vs `apply_patch`)\n// while agreeing on the kind. The emitted bytes are identical for both harnesses — Codex accepts the\n// same `permissionDecision: \"deny\"` + `permissionDecisionReason` + `systemMessage` fields, and rejects\n// nothing we emit. It does hard-reject an EMPTY `permissionDecisionReason` where Claude tolerates one,\n// which is why emitDeny below refuses to send one.\n// webpieces-disable no-function-outside-class -- the PreToolUse wire shape itself, module-scope beside emitDeny/emitAllow by design, and it must stay callable from a tree too broken to build a DI container\nexport function denyJson(event: AgentHookEvent | null, reason: string): string {\n // NEVER an empty reason, and the check lives HERE because this function owns the wire shape. Codex\n // hard-rejects a deny whose permissionDecisionReason is empty (Claude tolerates it and shows the\n // human nothing), so an empty one is not a cosmetic defect — it is a block that silently fails to\n // block. Every call site passes prose; this is the backstop that keeps a future one from turning a\n // deny into a protocol error.\n const safe = reason.trim() === '' ? '[ai-hooks] blocked, but the guard produced no reason — failing closed.' : reason;\n const hookSpecificOutput = {\n hookEventName: 'PreToolUse',\n permissionDecision: 'deny',\n permissionDecisionReason: safe,\n };\n // Bash only: permissionDecisionReason is NOT user-visible, so add the red systemMessage.\n if (event !== null && event.kind === 'Bash') {\n return JSON.stringify({ systemMessage: redSystemMessage(safe), hookSpecificOutput });\n }\n // Write/Edit/MultiEdit (and anything else): reason renders red natively; no systemMessage.\n return JSON.stringify({ hookSpecificOutput });\n}\n\n// THE DENY, AS A VALUE. `reason` is surfaced to both the user (terminal UI) and the model; the event's\n// kind selects whether the red `systemMessage` is added (Bash) or omitted (file tools) — see denyJson.\n//\n// This is the ONE boundary every blocking path passes through, which is why the per-invocation audit\n// line is flushed HERE: the `calls/` stream carries the outcome of its own call, and the outcome is not\n// known until this point. `rule` names what blocked (or '-'), for the line's `rule=` field.\n//\n// `fault` is the L0 fault code when the block IS an L0 fault (S/C/Y — the three decided here in JS,\n// where the sh shim's own `fault=` stamp can never reach), else '-'. Stamping it at this ONE boundary is\n// what makes `grep 'fault=S'` span the whole audit trail rather than only its sh half.\n//\n// It RETURNS the outcome instead of writing it. The write and the exit belong to HookApp, which owns\n// the injected stdout/exit ports — that separation is what lets a golden test read the exact bytes a\n// composed run produces. `denyForCrash` (hook-core) needs the value rather than the throw, because it\n// is already INSIDE the catch that would swallow one.\n// webpieces-disable no-function-outside-class -- the Claude Code PreToolUse protocol boundary; module-scope beside denyJson/allowOutcome by design, and it must stay callable from a tree too broken to build a DI container.\nexport function denyOutcome(event: AgentHookEvent | null, reason: string, rule: string = '-', fault: string = L0_FAULT_NONE): HookOutcome {\n // BLOCK_AI_CURE: every deny that reaches this boundary prints a cure the agent can act on — the\n // L0 faults name a command on the allowlist, and the L1/L2 guards print theirs. A deny needing a\n // HUMAN would have to say so at its own site; none does today, and inventing one here would be\n // guessing at the boundary rather than at the decision.\n invocationLog.finish('BLOCK_AI_CURE', rule, fault);\n return new HookOutcome(denyJson(event, reason) + '\\n', 0);\n}\n\n// The ALLOW, as a value. No JSON — a silent exit 0 is \"allow\" in the PreToolUse protocol.\n//\n// NOT exported, deliberately, where denyOutcome is: `denyForCrash` genuinely needs the deny in value\n// form because it is already inside the catch a throw would land in, and nothing needs the allow that\n// way. An exported one would be a second, externally-pickable spelling of \"allow\" sitting three lines\n// from `emitAllow` — precisely the shape an agent picks by accident.\n// webpieces-disable no-function-outside-class -- the PreToolUse protocol boundary, module-scope beside denyJson/denyOutcome by design, and it must stay callable from a tree too broken to build a DI container\nfunction allowOutcome(): HookOutcome {\n invocationLog.finish('ALLOW', '-');\n return new HookOutcome('', 0);\n}\n\n// Block the tool call and END the invocation from wherever in the pipeline we are — see HookTerminated\n// for why the terminal control flow is a throw now rather than a `process.exit` at the call site. Still\n// typed `never`: nothing after a call to this runs.\n// webpieces-disable no-function-outside-class -- the Claude Code PreToolUse protocol boundary; module-scope beside denyJson/emitAllow by design, and it must stay callable from a tree too broken to build a DI container.\nexport function emitDeny(event: AgentHookEvent | null, reason: string, rule: string = '-', fault: string = L0_FAULT_NONE): never {\n throw new HookTerminated(denyOutcome(event, reason, rule, fault));\n}\n\n// Allow the tool call and END the invocation. See emitDeny.\n// webpieces-disable no-function-outside-class -- the PreToolUse protocol boundary, module-scope beside denyJson/emitDeny by design, and it must stay callable from a tree too broken to build a DI container\nexport function emitAllow(): never {\n throw new HookTerminated(allowOutcome());\n}\n"]}
@@ -0,0 +1,26 @@
1
+ import { AgentPayload } from './agent-payload';
2
+ import { AgentHookEvent } from '../core/agent-event';
3
+ /**
4
+ * Morphs a Claude Code PreToolUse payload into the one normalized `AgentHookEvent`.
5
+ *
6
+ * This is the SAME `normalizeToolKind` / `normalizeToolInput` logic that used to live inline in
7
+ * hook-core.ts, moved out unchanged so hook-core is written once for every harness. Claude Code is the
8
+ * harness every developer uses today, so nothing about the mapping is allowed to move: `Write` still
9
+ * becomes one edit of `content` against '', `Edit` one edit of `old_string`→`new_string`, `MultiEdit`
10
+ * one per entry, and a file tool with no `file_path` still ends up allowed (kind `Ignored`).
11
+ */
12
+ export declare class ClaudeCodeAdapter {
13
+ /**
14
+ * What is known from the ENVELOPE ALONE, touching nothing but `tool_name` and the identity fields.
15
+ *
16
+ * Not a second spelling of `toEvent` — a different question, asked at a moment when the answer to
17
+ * the other one may not exist. `toEvent` reads `tool_input`, and a payload whose `tool_input` is
18
+ * missing makes it throw; the crash then still has to be DENIED, and the deny still has to know
19
+ * whether it is decorating a Bash block (which needs the red `systemMessage`) or a file block
20
+ * (which does not). This is the shape that answers that, and it cannot fail.
21
+ */
22
+ envelope(payload: AgentPayload): AgentHookEvent;
23
+ toEvent(payload: AgentPayload, cwd: string): AgentHookEvent;
24
+ private kindOf;
25
+ private fileOperations;
26
+ }
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ClaudeCodeAdapter = void 0;
4
+ const agent_event_1 = require("../core/agent-event");
5
+ const types_1 = require("../core/types");
6
+ /**
7
+ * The Claude Code tools that enter the file/edit rule pipeline. `Read` is deliberately NOT here — it
8
+ * has its own fast path and only one guard may see it.
9
+ */
10
+ const HANDLED_FILE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit']);
11
+ /**
12
+ * Morphs a Claude Code PreToolUse payload into the one normalized `AgentHookEvent`.
13
+ *
14
+ * This is the SAME `normalizeToolKind` / `normalizeToolInput` logic that used to live inline in
15
+ * hook-core.ts, moved out unchanged so hook-core is written once for every harness. Claude Code is the
16
+ * harness every developer uses today, so nothing about the mapping is allowed to move: `Write` still
17
+ * becomes one edit of `content` against '', `Edit` one edit of `old_string`→`new_string`, `MultiEdit`
18
+ * one per entry, and a file tool with no `file_path` still ends up allowed (kind `Ignored`).
19
+ */
20
+ class ClaudeCodeAdapter {
21
+ /**
22
+ * What is known from the ENVELOPE ALONE, touching nothing but `tool_name` and the identity fields.
23
+ *
24
+ * Not a second spelling of `toEvent` — a different question, asked at a moment when the answer to
25
+ * the other one may not exist. `toEvent` reads `tool_input`, and a payload whose `tool_input` is
26
+ * missing makes it throw; the crash then still has to be DENIED, and the deny still has to know
27
+ * whether it is decorating a Bash block (which needs the red `systemMessage`) or a file block
28
+ * (which does not). This is the shape that answers that, and it cannot fail.
29
+ */
30
+ envelope(payload) {
31
+ return new agent_event_1.AgentHookEvent('claude-code', this.kindOf(payload.tool_name), payload.tool_name, payload.cwd ?? '', payload.session_id ?? '', payload.agent_id ?? '', payload.agent_type ?? '', [], null, []);
32
+ }
33
+ toEvent(payload, cwd) {
34
+ const kind = this.kindOf(payload.tool_name);
35
+ const toolInput = payload.tool_input;
36
+ const bash = kind === 'Bash' ? new types_1.NormalizedBashInput(toolInput.command ?? '') : null;
37
+ const reads = kind === 'Read' ? [toolInput.file_path ?? ''] : [];
38
+ const files = kind === 'File' ? this.fileOperations(payload.tool_name, toolInput) : [];
39
+ // A file tool that named no file has nothing to judge; fall back to Ignored so the hook allows
40
+ // it, exactly as the old `if (!input) emitAllow()` did.
41
+ const effective = kind === 'File' && files.length === 0 ? 'Ignored' : kind;
42
+ return new agent_event_1.AgentHookEvent('claude-code', effective, payload.tool_name, cwd, payload.session_id ?? '', payload.agent_id ?? '', payload.agent_type ?? '', files, bash, reads);
43
+ }
44
+ kindOf(toolName) {
45
+ if (toolName === 'Bash')
46
+ return 'Bash';
47
+ if (toolName === 'Read')
48
+ return 'Read';
49
+ if (HANDLED_FILE_TOOLS.has(toolName))
50
+ return 'File';
51
+ return 'Ignored';
52
+ }
53
+ fileOperations(toolKind, toolInput) {
54
+ const filePath = toolInput.file_path;
55
+ if (!filePath)
56
+ return [];
57
+ if (toolKind === 'Write') {
58
+ return [new agent_event_1.FileOperation(toolKind, new types_1.NormalizedToolInput(filePath, [new types_1.NormalizedEdit('', toolInput.content || '')]))];
59
+ }
60
+ if (toolKind === 'Edit') {
61
+ return [new agent_event_1.FileOperation(toolKind, new types_1.NormalizedToolInput(filePath, [new types_1.NormalizedEdit(toolInput.old_string || '', toolInput.new_string || '')]))];
62
+ }
63
+ const raw = Array.isArray(toolInput.edits) ? toolInput.edits : [];
64
+ const edits = raw.map((e) => new types_1.NormalizedEdit(e.old_string || '', e.new_string || ''));
65
+ return [new agent_event_1.FileOperation(toolKind, new types_1.NormalizedToolInput(filePath, edits))];
66
+ }
67
+ }
68
+ exports.ClaudeCodeAdapter = ClaudeCodeAdapter;
69
+ //# sourceMappingURL=claude-code-adapter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"claude-code-adapter.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/claude-code-adapter.ts"],"names":[],"mappings":"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","sourcesContent":["import { AgentPayload, AgentToolInput, AgentEditEntry } from './agent-payload';\nimport { AgentHookEvent, AgentEventKind, FileOperation } from '../core/agent-event';\nimport { NormalizedBashInput, NormalizedEdit, NormalizedToolInput, ToolKind } from '../core/types';\n\n/**\n * The Claude Code tools that enter the file/edit rule pipeline. `Read` is deliberately NOT here — it\n * has its own fast path and only one guard may see it.\n */\nconst HANDLED_FILE_TOOLS: ReadonlySet<string> = new Set(['Write', 'Edit', 'MultiEdit']);\n\n/**\n * Morphs a Claude Code PreToolUse payload into the one normalized `AgentHookEvent`.\n *\n * This is the SAME `normalizeToolKind` / `normalizeToolInput` logic that used to live inline in\n * hook-core.ts, moved out unchanged so hook-core is written once for every harness. Claude Code is the\n * harness every developer uses today, so nothing about the mapping is allowed to move: `Write` still\n * becomes one edit of `content` against '', `Edit` one edit of `old_string`→`new_string`, `MultiEdit`\n * one per entry, and a file tool with no `file_path` still ends up allowed (kind `Ignored`).\n */\nexport class ClaudeCodeAdapter {\n /**\n * What is known from the ENVELOPE ALONE, touching nothing but `tool_name` and the identity fields.\n *\n * Not a second spelling of `toEvent` — a different question, asked at a moment when the answer to\n * the other one may not exist. `toEvent` reads `tool_input`, and a payload whose `tool_input` is\n * missing makes it throw; the crash then still has to be DENIED, and the deny still has to know\n * whether it is decorating a Bash block (which needs the red `systemMessage`) or a file block\n * (which does not). This is the shape that answers that, and it cannot fail.\n */\n envelope(payload: AgentPayload): AgentHookEvent {\n return new AgentHookEvent(\n 'claude-code', this.kindOf(payload.tool_name), payload.tool_name,\n payload.cwd ?? '', payload.session_id ?? '', payload.agent_id ?? '', payload.agent_type ?? '',\n [], null, [],\n );\n }\n\n toEvent(payload: AgentPayload, cwd: string): AgentHookEvent {\n const kind = this.kindOf(payload.tool_name);\n const toolInput = payload.tool_input;\n const bash = kind === 'Bash' ? new NormalizedBashInput(toolInput.command ?? '') : null;\n const reads = kind === 'Read' ? [toolInput.file_path ?? ''] : [];\n const files = kind === 'File' ? this.fileOperations(payload.tool_name as ToolKind, toolInput) : [];\n // A file tool that named no file has nothing to judge; fall back to Ignored so the hook allows\n // it, exactly as the old `if (!input) emitAllow()` did.\n const effective: AgentEventKind = kind === 'File' && files.length === 0 ? 'Ignored' : kind;\n return new AgentHookEvent(\n 'claude-code', effective, payload.tool_name,\n cwd, payload.session_id ?? '', payload.agent_id ?? '', payload.agent_type ?? '',\n files, bash, reads,\n );\n }\n\n private kindOf(toolName: string): AgentEventKind {\n if (toolName === 'Bash') return 'Bash';\n if (toolName === 'Read') return 'Read';\n if (HANDLED_FILE_TOOLS.has(toolName)) return 'File';\n return 'Ignored';\n }\n\n private fileOperations(toolKind: ToolKind, toolInput: AgentToolInput): readonly FileOperation[] {\n const filePath = toolInput.file_path;\n if (!filePath) return [];\n if (toolKind === 'Write') {\n return [new FileOperation(toolKind, new NormalizedToolInput(filePath, [new NormalizedEdit('', toolInput.content || '')]))];\n }\n if (toolKind === 'Edit') {\n return [new FileOperation(toolKind, new NormalizedToolInput(filePath, [new NormalizedEdit(toolInput.old_string || '', toolInput.new_string || '')]))];\n }\n const raw = Array.isArray(toolInput.edits) ? toolInput.edits : [];\n const edits = raw.map((e: AgentEditEntry): NormalizedEdit => new NormalizedEdit(e.old_string || '', e.new_string || ''));\n return [new FileOperation(toolKind, new NormalizedToolInput(filePath, edits))];\n }\n}\n"]}
@@ -0,0 +1,19 @@
1
+ import { AgentPayload } from './agent-payload';
2
+ import { AgentHookEvent } from '../core/agent-event';
3
+ /**
4
+ * Morphs a Codex PreToolUse payload into the one normalized `AgentHookEvent`.
5
+ *
6
+ * Codex exposes exactly two tools this hook has anything to say about. Everything else measured in a
7
+ * live session — `webrun`, `collaborationspawn_agent`, `collaborationwait_agent`, `view_image`,
8
+ * `update_plan` — and every tool not yet seen maps to `Ignored` and is allowed immediately. That
9
+ * default is chosen on purpose: an unknown tool is one we cannot judge, and inventing a mapping for it
10
+ * would apply file rules to bytes that are not a file edit.
11
+ */
12
+ export declare class CodexAdapter {
13
+ private readonly patchParser;
14
+ private readonly readParity;
15
+ /** See ClaudeCodeAdapter.envelope — the pre-normalization shape the crash deny needs. */
16
+ envelope(payload: AgentPayload): AgentHookEvent;
17
+ toEvent(payload: AgentPayload, cwd: string): AgentHookEvent;
18
+ private kindOf;
19
+ }
@@ -0,0 +1,48 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CodexAdapter = void 0;
4
+ const rules_config_1 = require("@webpieces/rules-config");
5
+ const agent_event_1 = require("../core/agent-event");
6
+ const types_1 = require("../core/types");
7
+ const apply_patch_parse_1 = require("../core/apply-patch-parse");
8
+ const shell_read_parity_1 = require("../core/shell-read-parity");
9
+ /** Codex's shell tool. MEASURED: it reuses Claude's name — it is `Bash`, NOT `shell`. */
10
+ const CODEX_BASH = 'Bash';
11
+ /** Codex's ONLY file-editing tool. One envelope, many files, mixed operations. */
12
+ const CODEX_APPLY_PATCH = 'apply_patch';
13
+ /**
14
+ * Morphs a Codex PreToolUse payload into the one normalized `AgentHookEvent`.
15
+ *
16
+ * Codex exposes exactly two tools this hook has anything to say about. Everything else measured in a
17
+ * live session — `webrun`, `collaborationspawn_agent`, `collaborationwait_agent`, `view_image`,
18
+ * `update_plan` — and every tool not yet seen maps to `Ignored` and is allowed immediately. That
19
+ * default is chosen on purpose: an unknown tool is one we cannot judge, and inventing a mapping for it
20
+ * would apply file rules to bytes that are not a file edit.
21
+ */
22
+ class CodexAdapter {
23
+ patchParser = new apply_patch_parse_1.ApplyPatchParser();
24
+ readParity = new shell_read_parity_1.ShellReadParity();
25
+ /** See ClaudeCodeAdapter.envelope — the pre-normalization shape the crash deny needs. */
26
+ envelope(payload) {
27
+ return new agent_event_1.AgentHookEvent('codex', this.kindOf(payload.tool_name), payload.tool_name, payload.cwd ?? '', payload.session_id ?? '', payload.agent_id ?? '', payload.agent_type ?? '', [], null, []);
28
+ }
29
+ toEvent(payload, cwd) {
30
+ const kind = this.kindOf(payload.tool_name);
31
+ const command = payload.tool_input.command ?? '';
32
+ const bash = kind === 'Bash' ? new types_1.NormalizedBashInput(command) : null;
33
+ // Read parity: Codex has no Read tool, so a read arrives as `Bash` running a pager. Synthesized
34
+ // reads run the read guard IN ADDITION to the bash guards — see ../core/shell-read-parity.ts.
35
+ const reads = kind === 'Bash' ? this.readParity.readTargets(command, cwd, new rules_config_1.RepoRootFinder().resolveRepoRoot(cwd)) : [];
36
+ const files = kind === 'File' ? this.patchParser.parse(command, cwd) : [];
37
+ return new agent_event_1.AgentHookEvent('codex', kind, payload.tool_name, cwd, payload.session_id ?? '', payload.agent_id ?? '', payload.agent_type ?? '', files, bash, reads);
38
+ }
39
+ kindOf(toolName) {
40
+ if (toolName === CODEX_BASH)
41
+ return 'Bash';
42
+ if (toolName === CODEX_APPLY_PATCH)
43
+ return 'File';
44
+ return 'Ignored';
45
+ }
46
+ }
47
+ exports.CodexAdapter = CodexAdapter;
48
+ //# sourceMappingURL=codex-adapter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"codex-adapter.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/codex-adapter.ts"],"names":[],"mappings":";;;AAAA,0DAAyD;AAGzD,qDAAqE;AACrE,yCAAoD;AACpD,iEAA6D;AAC7D,iEAA4D;AAE5D,yFAAyF;AACzF,MAAM,UAAU,GAAG,MAAM,CAAC;AAC1B,kFAAkF;AAClF,MAAM,iBAAiB,GAAG,aAAa,CAAC;AAExC;;;;;;;;GAQG;AACH,MAAa,YAAY;IACJ,WAAW,GAAG,IAAI,oCAAgB,EAAE,CAAC;IACrC,UAAU,GAAG,IAAI,mCAAe,EAAE,CAAC;IAEpD,yFAAyF;IACzF,QAAQ,CAAC,OAAqB;QAC1B,OAAO,IAAI,4BAAc,CACrB,OAAO,EAAE,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC,SAAS,EAC1D,OAAO,CAAC,GAAG,IAAI,EAAE,EAAE,OAAO,CAAC,UAAU,IAAI,EAAE,EAAE,OAAO,CAAC,QAAQ,IAAI,EAAE,EAAE,OAAO,CAAC,UAAU,IAAI,EAAE,EAC7F,EAAE,EAAE,IAAI,EAAE,EAAE,CACf,CAAC;IACN,CAAC;IAED,OAAO,CAAC,OAAqB,EAAE,GAAW;QACtC,MAAM,IAAI,GAAG,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;QAC5C,MAAM,OAAO,GAAG,OAAO,CAAC,UAAU,CAAC,OAAO,IAAI,EAAE,CAAC;QACjD,MAAM,IAAI,GAAG,IAAI,KAAK,MAAM,CAAC,CAAC,CAAC,IAAI,2BAAmB,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;QACvE,gGAAgG;QAChG,8FAA8F;QAC9F,MAAM,KAAK,GAAG,IAAI,KAAK,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,UAAU,CAAC,WAAW,CAAC,OAAO,EAAE,GAAG,EAAE,IAAI,6BAAc,EAAE,CAAC,eAAe,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QAC1H,MAAM,KAAK,GAAG,IAAI,KAAK,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC,OAAO,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QAC1E,OAAO,IAAI,4BAAc,CACrB,OAAO,EAAE,IAAI,EAAE,OAAO,CAAC,SAAS,EAChC,GAAG,EAAE,OAAO,CAAC,UAAU,IAAI,EAAE,EAAE,OAAO,CAAC,QAAQ,IAAI,EAAE,EAAE,OAAO,CAAC,UAAU,IAAI,EAAE,EAC/E,KAAK,EAAE,IAAI,EAAE,KAAK,CACrB,CAAC;IACN,CAAC;IAEO,MAAM,CAAC,QAAgB;QAC3B,IAAI,QAAQ,KAAK,UAAU;YAAE,OAAO,MAAM,CAAC;QAC3C,IAAI,QAAQ,KAAK,iBAAiB;YAAE,OAAO,MAAM,CAAC;QAClD,OAAO,SAAS,CAAC;IACrB,CAAC;CACJ;AAjCD,oCAiCC","sourcesContent":["import { RepoRootFinder } from '@webpieces/rules-config';\n\nimport { AgentPayload } from './agent-payload';\nimport { AgentHookEvent, AgentEventKind } from '../core/agent-event';\nimport { NormalizedBashInput } from '../core/types';\nimport { ApplyPatchParser } from '../core/apply-patch-parse';\nimport { ShellReadParity } from '../core/shell-read-parity';\n\n/** Codex's shell tool. MEASURED: it reuses Claude's name — it is `Bash`, NOT `shell`. */\nconst CODEX_BASH = 'Bash';\n/** Codex's ONLY file-editing tool. One envelope, many files, mixed operations. */\nconst CODEX_APPLY_PATCH = 'apply_patch';\n\n/**\n * Morphs a Codex PreToolUse payload into the one normalized `AgentHookEvent`.\n *\n * Codex exposes exactly two tools this hook has anything to say about. Everything else measured in a\n * live session — `webrun`, `collaborationspawn_agent`, `collaborationwait_agent`, `view_image`,\n * `update_plan` — and every tool not yet seen maps to `Ignored` and is allowed immediately. That\n * default is chosen on purpose: an unknown tool is one we cannot judge, and inventing a mapping for it\n * would apply file rules to bytes that are not a file edit.\n */\nexport class CodexAdapter {\n private readonly patchParser = new ApplyPatchParser();\n private readonly readParity = new ShellReadParity();\n\n /** See ClaudeCodeAdapter.envelope — the pre-normalization shape the crash deny needs. */\n envelope(payload: AgentPayload): AgentHookEvent {\n return new AgentHookEvent(\n 'codex', this.kindOf(payload.tool_name), payload.tool_name,\n payload.cwd ?? '', payload.session_id ?? '', payload.agent_id ?? '', payload.agent_type ?? '',\n [], null, [],\n );\n }\n\n toEvent(payload: AgentPayload, cwd: string): AgentHookEvent {\n const kind = this.kindOf(payload.tool_name);\n const command = payload.tool_input.command ?? '';\n const bash = kind === 'Bash' ? new NormalizedBashInput(command) : null;\n // Read parity: Codex has no Read tool, so a read arrives as `Bash` running a pager. Synthesized\n // reads run the read guard IN ADDITION to the bash guards — see ../core/shell-read-parity.ts.\n const reads = kind === 'Bash' ? this.readParity.readTargets(command, cwd, new RepoRootFinder().resolveRepoRoot(cwd)) : [];\n const files = kind === 'File' ? this.patchParser.parse(command, cwd) : [];\n return new AgentHookEvent(\n 'codex', kind, payload.tool_name,\n cwd, payload.session_id ?? '', payload.agent_id ?? '', payload.agent_type ?? '',\n files, bash, reads,\n );\n }\n\n private kindOf(toolName: string): AgentEventKind {\n if (toolName === CODEX_BASH) return 'Bash';\n if (toolName === CODEX_APPLY_PATCH) return 'File';\n return 'Ignored';\n }\n}\n"]}
@@ -0,0 +1,30 @@
1
+ import { AgentHookEvent } from '../core/agent-event';
2
+ import { BlockedResult } from '../core/types';
3
+ export declare const CODEX_SUBAGENT_RULE = "codex-subagent-no-write-in-shared-tree";
4
+ /**
5
+ * A Codex SUBAGENT may not write into the tree it shares with its coordinator.
6
+ *
7
+ * This exists because of a MEASURED structural gap, not a style preference. Claude Code can hand a
8
+ * subagent its own git worktree (`isolation: "worktree"`), so two agents editing at once are editing
9
+ * two different checkouts. Codex cannot: `spawn_agent`'s schema is
10
+ * `{fork_turns?, message, model?, reasoning_effort?, task_name}` — there is no cwd, workdir or
11
+ * worktree parameter — and cwd resets to the repo root before every command, so a subagent cannot even
12
+ * put itself somewhere else. Every Codex subagent therefore writes into the coordinator's checkout,
13
+ * and concurrent subagents write into each other's.
14
+ *
15
+ * Reviewers are unaffected: they only read, and a read arrives as `Bash`.
16
+ *
17
+ * NOT a configurable rule yet, and that is deliberate. A rule registered with the engine must have an
18
+ * entry in webpieces.config.json, and the validator that would accept a new key is a RELEASE behind
19
+ * the source that defines it — adding both at once rejects the key as unknown and blocks every tool
20
+ * call in the repo. So the guard ships here, gated on `aiType === 'codex'`, and becomes a config-keyed
21
+ * rule in the follow-up PR that lands after the publish.
22
+ */
23
+ export declare class CodexSubagentSharedTreeGuard {
24
+ /**
25
+ * Returns a block when a Codex SUBAGENT's patch targets a file inside `root`, else null.
26
+ * Claude Code events return null unconditionally — the harness has real isolation.
27
+ */
28
+ check(event: AgentHookEvent, root: string): BlockedResult | null;
29
+ private isInside;
30
+ }
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CodexSubagentSharedTreeGuard = exports.CODEX_SUBAGENT_RULE = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const path = tslib_1.__importStar(require("path"));
6
+ const rules_config_1 = require("@webpieces/rules-config");
7
+ const types_1 = require("../core/types");
8
+ exports.CODEX_SUBAGENT_RULE = 'codex-subagent-no-write-in-shared-tree';
9
+ /**
10
+ * A Codex SUBAGENT may not write into the tree it shares with its coordinator.
11
+ *
12
+ * This exists because of a MEASURED structural gap, not a style preference. Claude Code can hand a
13
+ * subagent its own git worktree (`isolation: "worktree"`), so two agents editing at once are editing
14
+ * two different checkouts. Codex cannot: `spawn_agent`'s schema is
15
+ * `{fork_turns?, message, model?, reasoning_effort?, task_name}` — there is no cwd, workdir or
16
+ * worktree parameter — and cwd resets to the repo root before every command, so a subagent cannot even
17
+ * put itself somewhere else. Every Codex subagent therefore writes into the coordinator's checkout,
18
+ * and concurrent subagents write into each other's.
19
+ *
20
+ * Reviewers are unaffected: they only read, and a read arrives as `Bash`.
21
+ *
22
+ * NOT a configurable rule yet, and that is deliberate. A rule registered with the engine must have an
23
+ * entry in webpieces.config.json, and the validator that would accept a new key is a RELEASE behind
24
+ * the source that defines it — adding both at once rejects the key as unknown and blocks every tool
25
+ * call in the repo. So the guard ships here, gated on `aiType === 'codex'`, and becomes a config-keyed
26
+ * rule in the follow-up PR that lands after the publish.
27
+ */
28
+ class CodexSubagentSharedTreeGuard {
29
+ /**
30
+ * Returns a block when a Codex SUBAGENT's patch targets a file inside `root`, else null.
31
+ * Claude Code events return null unconditionally — the harness has real isolation.
32
+ */
33
+ check(event, root) {
34
+ if (event.aiType !== 'codex')
35
+ return null;
36
+ if (event.agentId === '')
37
+ return null;
38
+ const inside = event.files.filter((f) => this.isInside(f.input.filePath, root));
39
+ if (inside.length === 0)
40
+ return null;
41
+ const targets = inside.map((f) => path.relative(root, f.input.filePath)).join(', ');
42
+ const worktree = path.join(path.dirname(root), 'wt-<task-name>');
43
+ const error = new rules_config_1.RuleFailError(exports.CODEX_SUBAGENT_RULE, `A Codex subagent is writing into the tree it shares with its coordinator: ${targets}\n\n` +
44
+ `Codex cannot spawn a subagent into its own checkout — spawn_agent takes no cwd or worktree, ` +
45
+ `and cwd resets to the repo root before every command — so this edit lands in the same files ` +
46
+ `the coordinator and every sibling subagent are editing.`, undefined, undefined, [
47
+ new rules_config_1.Option(`Give this agent its own checkout and address every file by ABSOLUTE path inside it: git worktree add ${worktree} -b <branch>`, true),
48
+ new rules_config_1.Option('Hand the edit back to the coordinator and have this agent report what to change instead of changing it'),
49
+ ]);
50
+ return new types_1.BlockedResult((0, rules_config_1.renderRuleFailForAi)(error));
51
+ }
52
+ isInside(filePath, root) {
53
+ const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep;
54
+ return path.resolve(filePath).startsWith(rootWithSep);
55
+ }
56
+ }
57
+ exports.CodexSubagentSharedTreeGuard = CodexSubagentSharedTreeGuard;
58
+ //# sourceMappingURL=codex-subagent-guard.js.map