@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.
- package/package.json +4 -2
- package/src/adapters/agent-adapters.d.ts +13 -0
- package/src/adapters/agent-adapters.js +23 -0
- package/src/adapters/agent-adapters.js.map +1 -0
- package/src/adapters/agent-payload.d.ts +48 -0
- package/src/adapters/agent-payload.js +30 -0
- package/src/adapters/agent-payload.js.map +1 -0
- package/src/adapters/agent-response.d.ts +6 -0
- package/src/adapters/{claude-code-response.js → agent-response.js} +56 -22
- package/src/adapters/agent-response.js.map +1 -0
- package/src/adapters/claude-code-adapter.d.ts +26 -0
- package/src/adapters/claude-code-adapter.js +69 -0
- package/src/adapters/claude-code-adapter.js.map +1 -0
- package/src/adapters/codex-adapter.d.ts +19 -0
- package/src/adapters/codex-adapter.js +48 -0
- package/src/adapters/codex-adapter.js.map +1 -0
- package/src/adapters/codex-subagent-guard.d.ts +30 -0
- package/src/adapters/codex-subagent-guard.js +58 -0
- package/src/adapters/codex-subagent-guard.js.map +1 -0
- package/src/adapters/detect-ai.d.ts +36 -0
- package/src/adapters/detect-ai.js +47 -0
- package/src/adapters/detect-ai.js.map +1 -0
- package/src/adapters/guards-hook.d.ts +1 -0
- package/src/adapters/guards-hook.js +21 -6
- package/src/adapters/guards-hook.js.map +1 -1
- package/src/adapters/hook-app-fixtures.d.ts +68 -0
- package/src/adapters/hook-app-fixtures.js +175 -0
- package/src/adapters/hook-app-fixtures.js.map +1 -0
- package/src/adapters/hook-app.d.ts +61 -0
- package/src/adapters/hook-app.js +110 -0
- package/src/adapters/hook-app.js.map +1 -0
- package/src/adapters/hook-core.d.ts +15 -6
- package/src/adapters/hook-core.js +157 -139
- package/src/adapters/hook-core.js.map +1 -1
- package/src/adapters/hook-outcome.d.ts +46 -0
- package/src/adapters/hook-outcome.js +60 -0
- package/src/adapters/hook-outcome.js.map +1 -0
- package/src/adapters/hook-ports.d.ts +57 -0
- package/src/adapters/hook-ports.js +90 -0
- package/src/adapters/hook-ports.js.map +1 -0
- package/src/adapters/rules-hook.d.ts +1 -0
- package/src/adapters/rules-hook.js +19 -5
- package/src/adapters/rules-hook.js.map +1 -1
- package/src/core/agent-event.d.ts +65 -0
- package/src/core/agent-event.js +59 -0
- package/src/core/agent-event.js.map +1 -0
- package/src/core/apply-patch-parse.d.ts +36 -0
- package/src/core/apply-patch-parse.js +154 -0
- package/src/core/apply-patch-parse.js.map +1 -0
- package/src/core/delete-scoped-rules.d.ts +8 -0
- package/src/core/delete-scoped-rules.js +31 -0
- package/src/core/delete-scoped-rules.js.map +1 -0
- package/src/core/runner.js +2 -1
- package/src/core/runner.js.map +1 -1
- package/src/core/shell-read-parity.d.ts +22 -0
- package/src/core/shell-read-parity.js +145 -0
- package/src/core/shell-read-parity.js.map +1 -0
- package/src/core/types.d.ts +1 -1
- package/src/core/types.js.map +1 -1
- package/src/index.d.ts +2 -0
- package/src/index.js +11 -1
- package/src/index.js.map +1 -1
- package/src/adapters/claude-code-response.d.ts +0 -3
- 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.
|
|
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.
|
|
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
|
|
3
|
-
//
|
|
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
|
-
|
|
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:
|
|
76
|
+
permissionDecisionReason: safe,
|
|
61
77
|
};
|
|
62
78
|
// Bash only: permissionDecisionReason is NOT user-visible, so add the red systemMessage.
|
|
63
|
-
if (
|
|
64
|
-
return JSON.stringify({ systemMessage: redSystemMessage(
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
93
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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=
|
|
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
|