@webpieces/ai-hook-rules 0.4.702 → 0.4.704

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 (46) hide show
  1. package/package.json +2 -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 +4 -0
  9. package/src/adapters/{claude-code-response.js → agent-response.js} +26 -11
  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/hook-core.d.ts +5 -5
  24. package/src/adapters/hook-core.js +117 -113
  25. package/src/adapters/hook-core.js.map +1 -1
  26. package/src/core/agent-event.d.ts +65 -0
  27. package/src/core/agent-event.js +59 -0
  28. package/src/core/agent-event.js.map +1 -0
  29. package/src/core/apply-patch-parse.d.ts +36 -0
  30. package/src/core/apply-patch-parse.js +154 -0
  31. package/src/core/apply-patch-parse.js.map +1 -0
  32. package/src/core/delete-scoped-rules.d.ts +8 -0
  33. package/src/core/delete-scoped-rules.js +31 -0
  34. package/src/core/delete-scoped-rules.js.map +1 -0
  35. package/src/core/runner.js +2 -1
  36. package/src/core/runner.js.map +1 -1
  37. package/src/core/shell-read-parity.d.ts +22 -0
  38. package/src/core/shell-read-parity.js +145 -0
  39. package/src/core/shell-read-parity.js.map +1 -0
  40. package/src/core/types.d.ts +1 -1
  41. package/src/core/types.js.map +1 -1
  42. package/src/index.d.ts +2 -0
  43. package/src/index.js +11 -1
  44. package/src/index.js.map +1 -1
  45. package/src/adapters/claude-code-response.d.ts +0 -3
  46. 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.702",
3
+ "version": "0.4.704",
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,7 @@
25
25
  "directory": "packages/tooling/ai-hook-rules"
26
26
  },
27
27
  "dependencies": {
28
- "@webpieces/rules-config": "0.4.702"
28
+ "@webpieces/rules-config": "0.4.704"
29
29
  },
30
30
  "publishConfig": {
31
31
  "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,4 @@
1
+ import { AgentHookEvent } from '../core/agent-event';
2
+ export declare function denyJson(event: AgentHookEvent | null, reason: string): string;
3
+ export declare function emitDeny(event: AgentHookEvent | null, reason: string, rule?: string, fault?: string): never;
4
+ 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.
@@ -47,27 +47,41 @@ const ESC = String.fromCharCode(0x1b);
47
47
  * The reset (`[0m`) still closes the sequence on the same line it opened, so nothing leaks into the
48
48
  * body or into whatever the terminal prints next.
49
49
  */
50
+ // 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
51
  function redSystemMessage(reason) {
51
52
  const nl = reason.indexOf('\n');
52
53
  if (nl < 0)
53
54
  return `${ESC}[31;1m🛑 ${reason}${ESC}[0m`;
54
55
  return `${ESC}[31;1m🛑 ${reason.slice(0, nl)}${ESC}[0m${reason.slice(nl)}`;
55
56
  }
56
- function denyJson(reason, toolName) {
57
+ // Takes the EVENT rather than a tool-name string, because the one thing this decision needs is the
58
+ // event's routing kind, and the harnesses spell their tool names differently (`Bash` vs `apply_patch`)
59
+ // while agreeing on the kind. The emitted bytes are identical for both harnesses — Codex accepts the
60
+ // same `permissionDecision: "deny"` + `permissionDecisionReason` + `systemMessage` fields, and rejects
61
+ // nothing we emit. It does hard-reject an EMPTY `permissionDecisionReason` where Claude tolerates one,
62
+ // which is why emitDeny below refuses to send one.
63
+ // 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
64
+ function denyJson(event, reason) {
65
+ // NEVER an empty reason, and the check lives HERE because this function owns the wire shape. Codex
66
+ // hard-rejects a deny whose permissionDecisionReason is empty (Claude tolerates it and shows the
67
+ // human nothing), so an empty one is not a cosmetic defect — it is a block that silently fails to
68
+ // block. Every call site passes prose; this is the backstop that keeps a future one from turning a
69
+ // deny into a protocol error.
70
+ const safe = reason.trim() === '' ? '[ai-hooks] blocked, but the guard produced no reason — failing closed.' : reason;
57
71
  const hookSpecificOutput = {
58
72
  hookEventName: 'PreToolUse',
59
73
  permissionDecision: 'deny',
60
- permissionDecisionReason: reason,
74
+ permissionDecisionReason: safe,
61
75
  };
62
76
  // Bash only: permissionDecisionReason is NOT user-visible, so add the red systemMessage.
63
- if (toolName === 'Bash') {
64
- return JSON.stringify({ systemMessage: redSystemMessage(reason), hookSpecificOutput });
77
+ if (event !== null && event.kind === 'Bash') {
78
+ return JSON.stringify({ systemMessage: redSystemMessage(safe), hookSpecificOutput });
65
79
  }
66
80
  // Write/Edit/MultiEdit (and anything else): reason renders red natively; no systemMessage.
67
81
  return JSON.stringify({ hookSpecificOutput });
68
82
  }
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.
83
+ // Block the tool call and surface `reason` to both the user (terminal UI) and the model. The event's
84
+ // kind selects whether the red `systemMessage` is added (Bash) or omitted (file tools) — see denyJson.
71
85
  // emitDeny/emitAllow are the hook's designated terminal boundary — the exit code IS the Claude Code
72
86
  // PreToolUse protocol (exit 0 + JSON = the contract), so the process.exit stays and is allowlisted.
73
87
  //
@@ -79,20 +93,21 @@ function denyJson(reason, toolName) {
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
95
  // 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) {
96
+ function emitDeny(event, reason, rule = '-', fault = l0_fault_codes_1.L0_FAULT_NONE) {
83
97
  // BLOCK_AI_CURE: every deny that reaches this boundary prints a cure the agent can act on — the
84
98
  // L0 faults name a command on the allowlist, and the L1/L2 guards print theirs. A deny needing a
85
99
  // HUMAN would have to say so at its own site; none does today, and inventing one here would be
86
100
  // guessing at the boundary rather than at the decision.
87
101
  decision_log_1.invocationLog.finish('BLOCK_AI_CURE', rule, fault);
88
- process.stdout.write(denyJson(reason, toolName) + '\n');
102
+ process.stdout.write(denyJson(event, reason) + '\n');
89
103
  // 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
104
  process.exit(0);
91
105
  }
92
106
  // Allow the tool call. No JSON needed — a silent exit 0 is "allow" in the PreToolUse protocol.
107
+ // 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
93
108
  function emitAllow() {
94
109
  decision_log_1.invocationLog.finish('ALLOW', '-');
95
110
  // webpieces-disable no-process-exit-outside-main -- hook exit-code IS the Claude Code PreToolUse protocol (silent exit 0 = "allow"); designated terminal boundary.
96
111
  process.exit(0);
97
112
  }
98
- //# sourceMappingURL=claude-code-response.js.map
113
+ //# 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';\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// Block the tool call and surface `reason` 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// emitDeny/emitAllow are the hook's designated terminal boundary — the exit code IS the Claude Code\n// PreToolUse protocol (exit 0 + JSON = the contract), so the process.exit stays and is allowlisted.\n//\n// Being the ONE boundary every path exits through is also why the per-invocation audit line is\n// 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// 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 // 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 process.stdout.write(denyJson(event, reason) + '\\n');\n // webpieces-disable no-process-exit-outside-main -- hook exit-code IS the Claude Code PreToolUse protocol (exit 0 + JSON = the contract); designated terminal boundary.\n process.exit(0);\n}\n\n// Allow the tool call. No JSON needed — a silent exit 0 is \"allow\" in the PreToolUse protocol.\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 invocationLog.finish('ALLOW', '-');\n // webpieces-disable no-process-exit-outside-main -- hook exit-code IS the Claude Code PreToolUse protocol (silent exit 0 = \"allow\"); designated terminal boundary.\n process.exit(0);\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
@@ -0,0 +1 @@
1
+ {"version":3,"file":"codex-subagent-guard.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/codex-subagent-guard.ts"],"names":[],"mappings":";;;;AAAA,mDAA6B;AAE7B,0DAAqF;AAGrF,yCAA8C;AAEjC,QAAA,mBAAmB,GAAG,wCAAwC,CAAC;AAE5E;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAa,4BAA4B;IACrC;;;OAGG;IACH,KAAK,CAAC,KAAqB,EAAE,IAAY;QACrC,IAAI,KAAK,CAAC,MAAM,KAAK,OAAO;YAAE,OAAO,IAAI,CAAC;QAC1C,IAAI,KAAK,CAAC,OAAO,KAAK,EAAE;YAAE,OAAO,IAAI,CAAC;QACtC,MAAM,MAAM,GAAG,KAAK,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAgB,EAAW,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC,CAAC;QACxG,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,IAAI,CAAC;QACrC,MAAM,OAAO,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC,CAAgB,EAAU,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC3G,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,gBAAgB,CAAC,CAAC;QACjE,MAAM,KAAK,GAAG,IAAI,4BAAa,CAC3B,2BAAmB,EACnB,6EAA6E,OAAO,MAAM;YAC1F,8FAA8F;YAC9F,8FAA8F;YAC9F,yDAAyD,EACzD,SAAS,EACT,SAAS,EACT;YACI,IAAI,qBAAM,CAAC,wGAAwG,QAAQ,cAAc,EAAE,IAAI,CAAC;YAChJ,IAAI,qBAAM,CAAC,wGAAwG,CAAC;SACvH,CACJ,CAAC;QACF,OAAO,IAAI,qBAAa,CAAC,IAAA,kCAAmB,EAAC,KAAK,CAAC,CAAC,CAAC;IACzD,CAAC;IAEO,QAAQ,CAAC,QAAgB,EAAE,IAAY;QAC3C,MAAM,WAAW,GAAG,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC;QACrE,OAAO,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC;IAC1D,CAAC;CACJ;AAhCD,oEAgCC","sourcesContent":["import * as path from 'path';\n\nimport { Option, RuleFailError, renderRuleFailForAi } from '@webpieces/rules-config';\n\nimport { AgentHookEvent, FileOperation } from '../core/agent-event';\nimport { BlockedResult } from '../core/types';\n\nexport const CODEX_SUBAGENT_RULE = 'codex-subagent-no-write-in-shared-tree';\n\n/**\n * A Codex SUBAGENT may not write into the tree it shares with its coordinator.\n *\n * This exists because of a MEASURED structural gap, not a style preference. Claude Code can hand a\n * subagent its own git worktree (`isolation: \"worktree\"`), so two agents editing at once are editing\n * two different checkouts. Codex cannot: `spawn_agent`'s schema is\n * `{fork_turns?, message, model?, reasoning_effort?, task_name}` — there is no cwd, workdir or\n * worktree parameter — and cwd resets to the repo root before every command, so a subagent cannot even\n * put itself somewhere else. Every Codex subagent therefore writes into the coordinator's checkout,\n * and concurrent subagents write into each other's.\n *\n * Reviewers are unaffected: they only read, and a read arrives as `Bash`.\n *\n * NOT a configurable rule yet, and that is deliberate. A rule registered with the engine must have an\n * entry in webpieces.config.json, and the validator that would accept a new key is a RELEASE behind\n * the source that defines it — adding both at once rejects the key as unknown and blocks every tool\n * call in the repo. So the guard ships here, gated on `aiType === 'codex'`, and becomes a config-keyed\n * rule in the follow-up PR that lands after the publish.\n */\nexport class CodexSubagentSharedTreeGuard {\n /**\n * Returns a block when a Codex SUBAGENT's patch targets a file inside `root`, else null.\n * Claude Code events return null unconditionally — the harness has real isolation.\n */\n check(event: AgentHookEvent, root: string): BlockedResult | null {\n if (event.aiType !== 'codex') return null;\n if (event.agentId === '') return null;\n const inside = event.files.filter((f: FileOperation): boolean => this.isInside(f.input.filePath, root));\n if (inside.length === 0) return null;\n const targets = inside.map((f: FileOperation): string => path.relative(root, f.input.filePath)).join(', ');\n const worktree = path.join(path.dirname(root), 'wt-<task-name>');\n const error = new RuleFailError(\n CODEX_SUBAGENT_RULE,\n `A Codex subagent is writing into the tree it shares with its coordinator: ${targets}\\n\\n` +\n `Codex cannot spawn a subagent into its own checkout — spawn_agent takes no cwd or worktree, ` +\n `and cwd resets to the repo root before every command — so this edit lands in the same files ` +\n `the coordinator and every sibling subagent are editing.`,\n undefined,\n undefined,\n [\n new Option(`Give this agent its own checkout and address every file by ABSOLUTE path inside it: git worktree add ${worktree} -b <branch>`, true),\n new Option('Hand the edit back to the coordinator and have this agent report what to change instead of changing it'),\n ],\n );\n return new BlockedResult(renderRuleFailForAi(error));\n }\n\n private isInside(filePath: string, root: string): boolean {\n const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep;\n return path.resolve(filePath).startsWith(rootWithSep);\n }\n}\n"]}
@@ -0,0 +1,36 @@
1
+ import { AiType } from '../core/agent-event';
2
+ /**
3
+ * THE discriminator, and the only one. Codex's PreToolUse envelope carries a REQUIRED `turn_id`;
4
+ * Claude Code's has no such key. Everything else in the two envelopes is the same key names
5
+ * (`hook_event_name`, `tool_name`, `tool_input`, `cwd`, `session_id`, `transcript_path`), which is
6
+ * exactly why one positive key is the whole test rather than a shape heuristic.
7
+ *
8
+ * Exported as a TWIN — an sh fragment and a JS predicate — because L0 has two halves that must
9
+ * answer the identical question: the rendered POSIX-sh shim (which has no JSON parser and scrapes
10
+ * text) and this binary (which has the parsed object). That is the same pattern
11
+ * ../bin/l0-allowlist.ts already uses for `L0_ALLOW_ERE_SH` / `L0_ALLOW_JS`, and detect-ai.spec.ts
12
+ * asserts the two agree over a corpus the same way.
13
+ *
14
+ * The sh half is an APPROXIMATION and says so out loud: it matches the six bytes `"turn_id":` in the
15
+ * raw payload, so a Claude payload that happened to embed that exact quoted-key-with-colon spelling
16
+ * inside a string value would be misread as Codex. Matching a JSON key from sh without a JSON parser
17
+ * cannot do better, the spelling is contrived (an agent grepping for `turn_id` types it bare), and
18
+ * the consequence of the miss is bounded: the Codex path is a SUPERSET of guards, never fewer.
19
+ *
20
+ * NOT WIRED INTO THE RENDERED SHIM IN THIS CHANGE. `committedShimStale()` compares the committed
21
+ * `.claude/webpieces/ai-hook.sh` against `renderShim()` of the INSTALLED release, so changing the
22
+ * renderer and regenerating the artifact together makes L0 fault S fire for everyone mid-upgrade.
23
+ * The constant ships here first; the shim consumes it a release later.
24
+ */
25
+ export declare const AI_TYPE_TOKEN_SH = "\"turn_id\":";
26
+ /**
27
+ * Sets `AI` to the literal `AiType` value — `codex` or `claude-code` — from `$PAYLOAD`. The values
28
+ * are the SAME strings the TypeScript union carries, so the twin test can compare them byte for byte
29
+ * instead of translating between two vocabularies (translation is where twins drift).
30
+ */
31
+ export declare const AI_TYPE_SH = "case \"$PAYLOAD\" in *'\"turn_id\":'*) AI=codex ;; *) AI=claude-code ;; esac";
32
+ /**
33
+ * JS twin of AI_TYPE_SH. Asks the precise question the sh half approximates: is `turn_id` a key of
34
+ * the top-level envelope?
35
+ */
36
+ export declare function detectAiType(payload: unknown): AiType;