@webpieces/ai-hook-rules 0.4.713 → 0.4.715
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 +2 -2
- package/src/adapters/detect-ai.d.ts +39 -6
- package/src/adapters/detect-ai.js +40 -6
- package/src/adapters/detect-ai.js.map +1 -1
- package/src/adapters/hook-core.d.ts +2 -1
- package/src/adapters/hook-core.js +13 -4
- package/src/adapters/hook-core.js.map +1 -1
- package/src/bin/l0-allowlist.d.ts +24 -1
- package/src/bin/l0-allowlist.js +58 -18
- package/src/bin/l0-allowlist.js.map +1 -1
- package/src/bin/l0-codex-read.d.ts +21 -0
- package/src/bin/l0-codex-read.js +109 -0
- package/src/bin/l0-codex-read.js.map +1 -0
- package/src/bin/l0-decide.d.ts +8 -1
- package/src/bin/l0-decide.js +14 -1
- package/src/bin/l0-decide.js.map +1 -1
- package/src/bin/shim-audit-log.js +2 -0
- package/src/bin/shim-audit-log.js.map +1 -1
- package/src/bin/shim-deny-reason.js +2 -1
- package/src/bin/shim-deny-reason.js.map +1 -1
- package/src/bin/shim.d.ts +1 -0
- package/src/bin/shim.js +16 -1
- package/src/bin/shim.js.map +1 -1
- package/src/core/l0-matrix.js +11 -4
- package/src/core/l0-matrix.js.map +1 -1
- package/src/core/l0-tooling-doc.js +8 -2
- package/src/core/l0-tooling-doc.js.map +1 -1
- package/src/core/runner.d.ts +2 -1
- package/src/core/runner.js +15 -17
- package/src/core/runner.js.map +1 -1
- package/src/core/shell-read-parity.d.ts +34 -0
- package/src/core/shell-read-parity.js +15 -5
- package/src/core/shell-read-parity.js.map +1 -1
- package/templates/ai-hook.sh +14 -2
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.715",
|
|
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.
|
|
28
|
+
"@webpieces/rules-config": "0.4.715",
|
|
29
29
|
"inversify": "7.10.4",
|
|
30
30
|
"reflect-metadata": "0.2.2"
|
|
31
31
|
},
|
|
@@ -11,11 +11,36 @@ import { AiType } from '../core/agent-event';
|
|
|
11
11
|
* ../bin/l0-allowlist.ts already uses for `L0_ALLOW_ERE_SH` / `L0_ALLOW_JS`, and detect-ai.spec.ts
|
|
12
12
|
* asserts the two agree over a corpus the same way.
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
14
|
+
* ─── THE SH HALF IS AN APPROXIMATION, AND SINCE L0 BRANCHES ON IT, HERE IS EXACTLY HOW FAR OFF ──────
|
|
15
|
+
*
|
|
16
|
+
* The JS half asks the precise question (`turn_id` is a key of the TOP-LEVEL envelope). The sh half has
|
|
17
|
+
* no JSON parser, so it asks a structural approximation of it: after deleting all whitespace, does the
|
|
18
|
+
* payload contain `{` or `,` immediately followed by `"turn_id":`? That is strictly narrower than the
|
|
19
|
+
* bare substring test it replaced, and the tightening is not cosmetic — `bin/l0-allowlist.ts` now gates
|
|
20
|
+
* an allowlist entry on this answer, so a wrong answer is a grant.
|
|
21
|
+
*
|
|
22
|
+
* WHAT IT CAN AND CANNOT DISTINGUISH:
|
|
23
|
+
*
|
|
24
|
+
* - A Claude payload MENTIONING the key in a command — `grep '"turn_id":' x.json` — is answered
|
|
25
|
+
* `claude-code`, and not by luck: JSON escapes every `"` inside a string value, so the bytes on the
|
|
26
|
+
* wire are `\"turn_id\":`, which contains no `"turn_id":` at all. There is no spelling of a shell
|
|
27
|
+
* command that puts the RAW token into the payload.
|
|
28
|
+
* - A NESTED `turn_id` key — an MCP tool_input that happens to carry one — IS misread as `codex`.
|
|
29
|
+
* This is the residual gap and sh cannot close it. Do not write a JSON parser in sh to try.
|
|
30
|
+
*
|
|
31
|
+
* WHY THE RESIDUAL GAP IS NOT A PRIVILEGE ESCALATION, stated as the property to preserve rather than
|
|
32
|
+
* as a reassurance: the ONLY thing the harness answer gates at L0 is `L0_CODEX_ALLOW_ERE` (built from
|
|
33
|
+
* `CODEX_READ_BODY_ERE` in ../bin/l0-codex-read.ts), which
|
|
34
|
+
* admits nothing but a single, unredirected, unchained READ (`cat`/`head`/`tail`/`less`/`more`/`bat`,
|
|
35
|
+
* or `sed -n '<range>p'`). Allowlist entry 1 — "any Read", with no path restriction — already grants
|
|
36
|
+
* Claude Code the identical capability under every L0 fault. So a misclassification hands a Claude
|
|
37
|
+
* session another SPELLING of a read it could already do, and never a capability it lacked. Anything
|
|
38
|
+
* added to the aiType-gated set later must be checked against that property, because it is what makes
|
|
39
|
+
* the approximation tolerable. `codex-l0-read.spec.ts` pins both halves of this.
|
|
40
|
+
*
|
|
41
|
+
* WHENEVER THE JS HALF IS RUNNING IT IS AUTHORITATIVE: the binary calls `detectAiType()` on the parsed
|
|
42
|
+
* envelope (see `enforceCommittedShim`), so the approximation decides only faults D/X/U/K, where the
|
|
43
|
+
* binary never runs at all.
|
|
19
44
|
*
|
|
20
45
|
* WIRED INTO THE RENDERED SHIM (`PARSE_PAYLOAD_SH` in ../bin/shim.ts), which is what makes the L0 audit
|
|
21
46
|
* line's `ai=` field possible. Note the release ordering that governs the ARTIFACT rather than this
|
|
@@ -28,8 +53,16 @@ export declare const AI_TYPE_TOKEN_SH = "\"turn_id\":";
|
|
|
28
53
|
* Sets `AI` to the literal `AiType` value — `codex` or `claude-code` — from `$PAYLOAD`. The values
|
|
29
54
|
* are the SAME strings the TypeScript union carries, so the twin test can compare them byte for byte
|
|
30
55
|
* instead of translating between two vocabularies (translation is where twins drift).
|
|
56
|
+
*
|
|
57
|
+
* Two steps, and both are load-bearing:
|
|
58
|
+
* 1. `tr -d` deletes every whitespace byte into `WP_AI_ENV`, so the structural test below does not
|
|
59
|
+
* have to spell "optional whitespace" — which a POSIX `case` glob cannot express — and a
|
|
60
|
+
* pretty-printed envelope is read exactly like a compact one. The stripped copy is used for THIS
|
|
61
|
+
* test only; `$PAYLOAD` itself is untouched and stays the input every `sed` scrape reads.
|
|
62
|
+
* 2. the glob requires `{` or `,` IMMEDIATELY before the quoted key, i.e. the structural context a
|
|
63
|
+
* real JSON key has and a mention inside a value does not.
|
|
31
64
|
*/
|
|
32
|
-
export declare const AI_TYPE_SH = "
|
|
65
|
+
export declare const AI_TYPE_SH = "WP_AI_ENV=\"$(printf '%s' \"$PAYLOAD\" | tr -d ' \\t\\n\\r')\"\ncase \"$WP_AI_ENV\" in *[{,]'\"turn_id\":'*) AI=codex ;; *) AI=claude-code ;; esac";
|
|
33
66
|
/**
|
|
34
67
|
* JS twin of AI_TYPE_SH. Asks the precise question the sh half approximates: is `turn_id` a key of
|
|
35
68
|
* the top-level envelope?
|
|
@@ -14,11 +14,36 @@ exports.detectAiType = detectAiType;
|
|
|
14
14
|
* ../bin/l0-allowlist.ts already uses for `L0_ALLOW_ERE_SH` / `L0_ALLOW_JS`, and detect-ai.spec.ts
|
|
15
15
|
* asserts the two agree over a corpus the same way.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
17
|
+
* ─── THE SH HALF IS AN APPROXIMATION, AND SINCE L0 BRANCHES ON IT, HERE IS EXACTLY HOW FAR OFF ──────
|
|
18
|
+
*
|
|
19
|
+
* The JS half asks the precise question (`turn_id` is a key of the TOP-LEVEL envelope). The sh half has
|
|
20
|
+
* no JSON parser, so it asks a structural approximation of it: after deleting all whitespace, does the
|
|
21
|
+
* payload contain `{` or `,` immediately followed by `"turn_id":`? That is strictly narrower than the
|
|
22
|
+
* bare substring test it replaced, and the tightening is not cosmetic — `bin/l0-allowlist.ts` now gates
|
|
23
|
+
* an allowlist entry on this answer, so a wrong answer is a grant.
|
|
24
|
+
*
|
|
25
|
+
* WHAT IT CAN AND CANNOT DISTINGUISH:
|
|
26
|
+
*
|
|
27
|
+
* - A Claude payload MENTIONING the key in a command — `grep '"turn_id":' x.json` — is answered
|
|
28
|
+
* `claude-code`, and not by luck: JSON escapes every `"` inside a string value, so the bytes on the
|
|
29
|
+
* wire are `\"turn_id\":`, which contains no `"turn_id":` at all. There is no spelling of a shell
|
|
30
|
+
* command that puts the RAW token into the payload.
|
|
31
|
+
* - A NESTED `turn_id` key — an MCP tool_input that happens to carry one — IS misread as `codex`.
|
|
32
|
+
* This is the residual gap and sh cannot close it. Do not write a JSON parser in sh to try.
|
|
33
|
+
*
|
|
34
|
+
* WHY THE RESIDUAL GAP IS NOT A PRIVILEGE ESCALATION, stated as the property to preserve rather than
|
|
35
|
+
* as a reassurance: the ONLY thing the harness answer gates at L0 is `L0_CODEX_ALLOW_ERE` (built from
|
|
36
|
+
* `CODEX_READ_BODY_ERE` in ../bin/l0-codex-read.ts), which
|
|
37
|
+
* admits nothing but a single, unredirected, unchained READ (`cat`/`head`/`tail`/`less`/`more`/`bat`,
|
|
38
|
+
* or `sed -n '<range>p'`). Allowlist entry 1 — "any Read", with no path restriction — already grants
|
|
39
|
+
* Claude Code the identical capability under every L0 fault. So a misclassification hands a Claude
|
|
40
|
+
* session another SPELLING of a read it could already do, and never a capability it lacked. Anything
|
|
41
|
+
* added to the aiType-gated set later must be checked against that property, because it is what makes
|
|
42
|
+
* the approximation tolerable. `codex-l0-read.spec.ts` pins both halves of this.
|
|
43
|
+
*
|
|
44
|
+
* WHENEVER THE JS HALF IS RUNNING IT IS AUTHORITATIVE: the binary calls `detectAiType()` on the parsed
|
|
45
|
+
* envelope (see `enforceCommittedShim`), so the approximation decides only faults D/X/U/K, where the
|
|
46
|
+
* binary never runs at all.
|
|
22
47
|
*
|
|
23
48
|
* WIRED INTO THE RENDERED SHIM (`PARSE_PAYLOAD_SH` in ../bin/shim.ts), which is what makes the L0 audit
|
|
24
49
|
* line's `ai=` field possible. Note the release ordering that governs the ARTIFACT rather than this
|
|
@@ -31,8 +56,17 @@ exports.AI_TYPE_TOKEN_SH = '"turn_id":';
|
|
|
31
56
|
* Sets `AI` to the literal `AiType` value — `codex` or `claude-code` — from `$PAYLOAD`. The values
|
|
32
57
|
* are the SAME strings the TypeScript union carries, so the twin test can compare them byte for byte
|
|
33
58
|
* instead of translating between two vocabularies (translation is where twins drift).
|
|
59
|
+
*
|
|
60
|
+
* Two steps, and both are load-bearing:
|
|
61
|
+
* 1. `tr -d` deletes every whitespace byte into `WP_AI_ENV`, so the structural test below does not
|
|
62
|
+
* have to spell "optional whitespace" — which a POSIX `case` glob cannot express — and a
|
|
63
|
+
* pretty-printed envelope is read exactly like a compact one. The stripped copy is used for THIS
|
|
64
|
+
* test only; `$PAYLOAD` itself is untouched and stays the input every `sed` scrape reads.
|
|
65
|
+
* 2. the glob requires `{` or `,` IMMEDIATELY before the quoted key, i.e. the structural context a
|
|
66
|
+
* real JSON key has and a mention inside a value does not.
|
|
34
67
|
*/
|
|
35
|
-
exports.AI_TYPE_SH = `
|
|
68
|
+
exports.AI_TYPE_SH = `WP_AI_ENV="$(printf '%s' "$PAYLOAD" | tr -d ' \\t\\n\\r')"
|
|
69
|
+
case "$WP_AI_ENV" in *[{,]'${exports.AI_TYPE_TOKEN_SH}'*) AI=codex ;; *) AI=claude-code ;; esac`;
|
|
36
70
|
/**
|
|
37
71
|
* JS twin of AI_TYPE_SH. Asks the precise question the sh half approximates: is `turn_id` a key of
|
|
38
72
|
* the top-level envelope?
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"detect-ai.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/detect-ai.ts"],"names":[],"mappings":";;;
|
|
1
|
+
{"version":3,"file":"detect-ai.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/detect-ai.ts"],"names":[],"mappings":";;;AA2EA,oCAIC;AA7ED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AACU,QAAA,gBAAgB,GAAG,YAAY,CAAC;AAE7C;;;;;;;;;;;;GAYG;AACU,QAAA,UAAU,GAAG;6BACG,wBAAgB,2CAA2C,CAAC;AAEzF;;;GAGG;AACH,6NAA6N;AAC7N,2MAA2M;AAC3M,SAAgB,YAAY,CAAC,OAAgB;IACzC,IAAI,OAAO,KAAK,IAAI,IAAI,OAAO,OAAO,KAAK,QAAQ;QAAE,OAAO,aAAa,CAAC;IAC1E,kIAAkI;IAClI,OAAO,SAAS,IAAK,OAAmC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,aAAa,CAAC;AACvF,CAAC","sourcesContent":["import { AiType } from '../core/agent-event';\n\n/**\n * THE discriminator, and the only one. Codex's PreToolUse envelope carries a REQUIRED `turn_id`;\n * Claude Code's has no such key. Everything else in the two envelopes is the same key names\n * (`hook_event_name`, `tool_name`, `tool_input`, `cwd`, `session_id`, `transcript_path`), which is\n * exactly why one positive key is the whole test rather than a shape heuristic.\n *\n * Exported as a TWIN — an sh fragment and a JS predicate — because L0 has two halves that must\n * answer the identical question: the rendered POSIX-sh shim (which has no JSON parser and scrapes\n * text) and this binary (which has the parsed object). That is the same pattern\n * ../bin/l0-allowlist.ts already uses for `L0_ALLOW_ERE_SH` / `L0_ALLOW_JS`, and detect-ai.spec.ts\n * asserts the two agree over a corpus the same way.\n *\n * ─── THE SH HALF IS AN APPROXIMATION, AND SINCE L0 BRANCHES ON IT, HERE IS EXACTLY HOW FAR OFF ──────\n *\n * The JS half asks the precise question (`turn_id` is a key of the TOP-LEVEL envelope). The sh half has\n * no JSON parser, so it asks a structural approximation of it: after deleting all whitespace, does the\n * payload contain `{` or `,` immediately followed by `\"turn_id\":`? That is strictly narrower than the\n * bare substring test it replaced, and the tightening is not cosmetic — `bin/l0-allowlist.ts` now gates\n * an allowlist entry on this answer, so a wrong answer is a grant.\n *\n * WHAT IT CAN AND CANNOT DISTINGUISH:\n *\n * - A Claude payload MENTIONING the key in a command — `grep '\"turn_id\":' x.json` — is answered\n * `claude-code`, and not by luck: JSON escapes every `\"` inside a string value, so the bytes on the\n * wire are `\\\"turn_id\\\":`, which contains no `\"turn_id\":` at all. There is no spelling of a shell\n * command that puts the RAW token into the payload.\n * - A NESTED `turn_id` key — an MCP tool_input that happens to carry one — IS misread as `codex`.\n * This is the residual gap and sh cannot close it. Do not write a JSON parser in sh to try.\n *\n * WHY THE RESIDUAL GAP IS NOT A PRIVILEGE ESCALATION, stated as the property to preserve rather than\n * as a reassurance: the ONLY thing the harness answer gates at L0 is `L0_CODEX_ALLOW_ERE` (built from\n * `CODEX_READ_BODY_ERE` in ../bin/l0-codex-read.ts), which\n * admits nothing but a single, unredirected, unchained READ (`cat`/`head`/`tail`/`less`/`more`/`bat`,\n * or `sed -n '<range>p'`). Allowlist entry 1 — \"any Read\", with no path restriction — already grants\n * Claude Code the identical capability under every L0 fault. So a misclassification hands a Claude\n * session another SPELLING of a read it could already do, and never a capability it lacked. Anything\n * added to the aiType-gated set later must be checked against that property, because it is what makes\n * the approximation tolerable. `codex-l0-read.spec.ts` pins both halves of this.\n *\n * WHENEVER THE JS HALF IS RUNNING IT IS AUTHORITATIVE: the binary calls `detectAiType()` on the parsed\n * envelope (see `enforceCommittedShim`), so the approximation decides only faults D/X/U/K, where the\n * binary never runs at all.\n *\n * WIRED INTO THE RENDERED SHIM (`PARSE_PAYLOAD_SH` in ../bin/shim.ts), which is what makes the L0 audit\n * line's `ai=` field possible. Note the release ordering that governs the ARTIFACT rather than this\n * constant: `committedShimStale()` compares the committed `.claude/webpieces/ai-hook.sh` against\n * `renderShim()` of the INSTALLED release, so the committed shim is NOT regenerated in the same PR that\n * changes the renderer — it is regenerated after that release publishes, or by `wp-upgrade-shim`.\n */\nexport const AI_TYPE_TOKEN_SH = '\"turn_id\":';\n\n/**\n * Sets `AI` to the literal `AiType` value — `codex` or `claude-code` — from `$PAYLOAD`. The values\n * are the SAME strings the TypeScript union carries, so the twin test can compare them byte for byte\n * instead of translating between two vocabularies (translation is where twins drift).\n *\n * Two steps, and both are load-bearing:\n * 1. `tr -d` deletes every whitespace byte into `WP_AI_ENV`, so the structural test below does not\n * have to spell \"optional whitespace\" — which a POSIX `case` glob cannot express — and a\n * pretty-printed envelope is read exactly like a compact one. The stripped copy is used for THIS\n * test only; `$PAYLOAD` itself is untouched and stays the input every `sed` scrape reads.\n * 2. the glob requires `{` or `,` IMMEDIATELY before the quoted key, i.e. the structural context a\n * real JSON key has and a mention inside a value does not.\n */\nexport const AI_TYPE_SH = `WP_AI_ENV=\"$(printf '%s' \"$PAYLOAD\" | tr -d ' \\\\t\\\\n\\\\r')\"\ncase \"$WP_AI_ENV\" in *[{,]'${AI_TYPE_TOKEN_SH}'*) AI=codex ;; *) AI=claude-code ;; esac`;\n\n/**\n * JS twin of AI_TYPE_SH. Asks the precise question the sh half approximates: is `turn_id` a key of\n * the top-level envelope?\n */\n// webpieces-disable no-any-unknown -- the argument IS unparsed JSON from another process's stdout; naming a type here would assert a shape we have not yet established, which is the question this function exists to answer\n// webpieces-disable no-function-outside-class -- twin of an sh fragment in the dependency-free adapter layer; it must stay callable on a tree too broken to build a DI container, exactly like isAllowed()\nexport function detectAiType(payload: unknown): AiType {\n if (payload === null || typeof payload !== 'object') return 'claude-code';\n // webpieces-disable no-any-unknown -- narrowing the same unparsed JSON; the index signature is the widest true statement about it\n return 'turn_id' in (payload as Record<string, unknown>) ? 'codex' : 'claude-code';\n}\n"]}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { HookMode } from '../core/types';
|
|
2
|
+
import { AiType } from '../core/agent-event';
|
|
2
3
|
import { HookOutcome } from './hook-outcome';
|
|
3
4
|
export type { HookMode };
|
|
4
5
|
export type ShimStaleDecision = 'allow-cure' | 'pass' | 'deny';
|
|
5
|
-
export declare function shimStaleRecoveryDecision(toolName: string, command: string, filePath: string): ShimStaleDecision;
|
|
6
|
+
export declare function shimStaleRecoveryDecision(toolName: string, command: string, filePath: string, aiType: AiType): ShimStaleDecision;
|
|
6
7
|
/**
|
|
7
8
|
* THE PIPELINE, from raw stdin bytes to the decision — the whole of `parse -> adapter -> runner ->
|
|
8
9
|
* emit`, as ONE function returning ONE value.
|
|
@@ -17,6 +17,7 @@ const agent_response_1 = require("./agent-response");
|
|
|
17
17
|
const hook_outcome_1 = require("./hook-outcome");
|
|
18
18
|
const agent_payload_1 = require("./agent-payload");
|
|
19
19
|
const agent_adapters_1 = require("./agent-adapters");
|
|
20
|
+
const detect_ai_1 = require("./detect-ai");
|
|
20
21
|
const codex_subagent_guard_1 = require("./codex-subagent-guard");
|
|
21
22
|
const shim_1 = require("../bin/shim");
|
|
22
23
|
const hook_registration_1 = require("../bin/hook-registration");
|
|
@@ -49,7 +50,11 @@ function handleBash(event, cwd, mode) {
|
|
|
49
50
|
for (const readPath of event.reads) {
|
|
50
51
|
handleRead(event, readPath, cwd, mode);
|
|
51
52
|
}
|
|
52
|
-
|
|
53
|
+
// The HARNESS travels with the command. Fault C and fault Y are decided inside the runner, and
|
|
54
|
+
// both consult the L0 allowlist — which since the gated entry exists cannot be asked without
|
|
55
|
+
// knowing who is calling. `event.aiType` is the adapter's answer, the same one that routed this
|
|
56
|
+
// call to the Codex read-parity loop above.
|
|
57
|
+
const result = (0, runner_1.runBash)(command, cwd, mode, event.aiType);
|
|
53
58
|
if (!result) {
|
|
54
59
|
(0, agent_response_1.emitAllow)();
|
|
55
60
|
}
|
|
@@ -167,8 +172,8 @@ function handleOneFile(event, file, cwd, mode) {
|
|
|
167
172
|
(0, agent_response_1.emitDeny)(event, result.report, blockingRule(result.report, 'file-guard'), result.fault);
|
|
168
173
|
}
|
|
169
174
|
// webpieces-disable no-function-outside-class -- pure decision helper beside the adapter's other module-scope functions; exported for direct unit testing.
|
|
170
|
-
function shimStaleRecoveryDecision(toolName, command, filePath) {
|
|
171
|
-
const allowed = (0, shim_1.isAllowed)(toolName, command, filePath);
|
|
175
|
+
function shimStaleRecoveryDecision(toolName, command, filePath, aiType) {
|
|
176
|
+
const allowed = (0, shim_1.isAllowed)(toolName, command, filePath, aiType);
|
|
172
177
|
if (allowed === 'pass')
|
|
173
178
|
return 'pass';
|
|
174
179
|
if (allowed === 'allow')
|
|
@@ -211,7 +216,11 @@ function enforceCommittedShim(payload, event, cwd, mode) {
|
|
|
211
216
|
const drifted = (0, hook_registration_1.managedSurfaceDrift)(shimRoot);
|
|
212
217
|
if (drifted.length === 0)
|
|
213
218
|
return;
|
|
214
|
-
|
|
219
|
+
// The harness comes from the RAW envelope too — `detectAiType` asks one question of one key, so it
|
|
220
|
+
// is as trustworthy here as the wire fields beside it, and it is the PRECISE test the shim's sh half
|
|
221
|
+
// can only approximate. Reading it off the normalized event instead would put a normalizer between
|
|
222
|
+
// L0 and its decision, which is the coupling the paragraph above exists to refuse.
|
|
223
|
+
const decision = shimStaleRecoveryDecision(payload.tool_name, payload.tool_input.command ?? '', payload.tool_input.file_path ?? '', (0, detect_ai_1.detectAiType)(payload));
|
|
215
224
|
if (decision === 'pass')
|
|
216
225
|
return;
|
|
217
226
|
if (decision === 'allow-cure')
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hook-core.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/hook-core.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\n\nimport { run, runBash, runRead } from '../core/runner';\nimport { branchStateHangTimeoutFor } from '../core/main-sync-timeout';\nimport { logRejection, extractRuleNames } from '../core/rejection-log';\nimport { logGuardDecision, GuardDecision, branchForLog, invocationLog, MATRIX_L0_BLOCK, matrixL2Row } from '../core/decision-log';\nimport { triggerMainSyncRefresh } from '../core/main-sync-refresh';\nimport { CONFIG_FILENAME } from '../core/load-config';\nimport { RepoRootFinder, renderRuleFailForAi } from '@webpieces/rules-config';\nimport { NormalizedToolInput, InformAiError, RuleFailError, HookMode, BlockedResult } from '../core/types';\nimport { AgentHookEvent, FileOperation } from '../core/agent-event';\nimport { toError } from '../core/to-error';\nimport { emitDeny, emitAllow, denyOutcome } from './agent-response';\nimport { HookOutcome, HookTerminated } from './hook-outcome';\nimport { AgentPayload, AgentPayloadParser } from './agent-payload';\nimport { AgentAdapters } from './agent-adapters';\nimport { CodexSubagentSharedTreeGuard, CODEX_SUBAGENT_RULE } from './codex-subagent-guard';\nimport { governingShimRoot, isAllowed, installedShimRulesVersion } from '../bin/shim';\nimport { managedSurfaceDrift } from '../bin/hook-registration';\nimport { shimStaleDenyReason } from '../bin/shim-deny-reason';\nimport { writeGuardMatrixDoc, guardMatrixPointer } from '../core/l0-matrix';\nimport { logStream, StreamIdentity } from '../core/log-stream';\nimport { aiTypeContext } from '../core/ai-type-context';\nimport { L0_FAULT_SHIM_STALE, L0_FAULT_NONE } from '../core/l0-fault-codes';\n\n// Which category of rules this hook invocation runs. The hook is split into two independently\n// installable PreToolUse hooks; each runs ONE category (the runner filters by it), and both can\n// receive file AND bash payloads:\n// - 'rules' → code-style rules (file/edit scope). Bash payloads pass through (no code rules apply).\n// - 'guards' → hookGuards section: bash git/PR guards on Bash AND file guards (feature-branch-guard)\n// on Write/Edit, PLUS a log-and-allow audit of Read. Matcher is Write|Edit|MultiEdit|Bash|Read.\n// - 'all' → both categories, used by the openclaw plugin adapter (a single before_tool_call hook).\nexport type { HookMode };\n\nconst ADAPTERS = new AgentAdapters();\nconst SUBAGENT_GUARD = new CodexSubagentSharedTreeGuard();\n\n// The rule name for a block's audit line: the FIRST rule the report cites, or `fallback` when the\n// report opens with no `[rule]` header (a hand-written guard message). Comma-joined when a report\n// cites several, so `rule=` never silently drops one.\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction blockingRule(report: string, fallback: string): string {\n const names = extractRuleNames(report);\n return names.length > 0 ? names.join(',') : fallback;\n}\n\n// webpieces-disable no-function-outside-class -- sibling of handleRead()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction handleBash(event: AgentHookEvent, cwd: string, mode: HookMode): never {\n const command = event.bash === null ? '' : event.bash.command;\n if (command.trim() === '') { emitAllow(); }\n\n // READ PARITY, and it can only ever be reached from a Codex event: the adapter leaves `reads`\n // empty for Claude Code, which has a real `Read` tool and its own fast path. A Codex read arrives\n // as `Bash` running a pager, so without this the read guard and the `calls/` audit trail see none\n // of them. The command is STILL run through the bash guards below — this adds a verdict, it never\n // replaces one.\n for (const readPath of event.reads) {\n handleRead(event, readPath, cwd, mode);\n }\n\n const result = runBash(command, cwd, mode);\n if (!result) { emitAllow(); }\n // NO DECISION LINE HERE. This used to write a generic `bash-guard` line because a Bash deny once\n // had no audit trail at all — but every layer now records its own: L1 into `L1-location/` with its\n // row, L2's guards into `L2-decisions/` with their rule and cache, and emitDeny below stamps the\n // call-level outcome onto `calls/`. So this was the THIRD line for one block, and the worst of the\n // three: it re-resolved the root from `cwd` via RepoRootFinder, which is not necessarily the tree\n // the guard actually judged, so a `cd`-relocated command scattered one block across two different\n // `.webpieces` directories.\n //\n // Bash deny → the event's kind is 'Bash', so denyJson adds the ANSI-red systemMessage (the only\n // field a Bash deny shows the human; permissionDecisionReason is invisible on Bash). See\n // agent-response.ts.\n emitDeny(event, result.report, blockingRule(result.report, 'bash-guard'), result.fault);\n}\n\n/**\n * The read-scoped guard pass. Returns normally to ALLOW; only calls emitDeny when the guard fires.\n *\n * Wrapped in its own catch that swallows into an allow. Every other path in this hook fails CLOSED,\n * and that is right for edits and shell commands — but a crash here would block the agent from\n * READING, which includes reading webpieces.config.json to turn the offending guard off. So this one\n * path deliberately inverts the policy: a broken read-guard degrades to a no-op, never to a wedge.\n */\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction handleRead(event: AgentHookEvent, filePath: string, cwd: string, mode: HookMode): void {\n if (filePath === '') return;\n let result: BlockedResult | null = null;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n result = runRead(filePath, cwd, mode);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return; // fail OPEN — see the doc comment\n }\n if (!result) return;\n logRejection('Read', new NormalizedToolInput(filePath, []), result, cwd);\n emitDeny(event, result.report, blockingRule(result.report, 'read-guard'), result.fault);\n}\n\n/**\n * Read-only tools (Read): audit-log, warm the main-sync cache, then run the ONE read-scoped guard\n * (read-stale-guard) and allow. Runs BEFORE the general rule engine — no code-style rule ever sees a\n * Read, and the only way this path can deny is a stale `main`. The audit trail still records every\n * file the AI opened (see setup.ts).\n *\n * Returns normally ONLY when the event is not a Read; otherwise it ends the invocation.\n */\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction handleReadFastPath(event: AgentHookEvent, cwd: string, mode: HookMode): void {\n if (event.kind !== 'Read') return;\n const readPath = event.reads.length > 0 ? event.reads[0] : '';\n if (mode !== 'rules') {\n invocationLog.begin(cwd, event.rawToolName, readPath);\n // Reads vastly outnumber edits, so refreshing here is what actually keeps the shared\n // main-sync cache warm for feature-branch-guard. Detached; never slows the read.\n triggerMainSyncRefresh(cwd, branchStateHangTimeoutFor(cwd));\n }\n handleRead(event, readPath, cwd, mode);\n emitAllow();\n}\n\n/**\n * The file/edit pipeline, run once per file the call touches.\n *\n * `event.files` is a LIST because ONE Codex `apply_patch` carries many files with mixed operations.\n * A Claude Code event always has exactly one entry, so the loop runs once and the behaviour is the\n * single-file behaviour it has always had.\n */\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleRead() in this module; the adapter is module-scope functions by design\nfunction handleFileTool(event: AgentHookEvent, cwd: string, mode: HookMode): never {\n if (event.files.length === 0) { emitAllow(); }\n\n // A Codex SUBAGENT writing into the tree it shares with its coordinator. Returns null for every\n // Claude Code event — that harness can hand a subagent its own worktree, and does.\n const subagentBlock = SUBAGENT_GUARD.check(event, new RepoRootFinder().resolveRepoRoot(cwd));\n if (subagentBlock) {\n emitDeny(event, subagentBlock.report, CODEX_SUBAGENT_RULE, subagentBlock.fault);\n }\n\n for (const file of event.files) {\n handleOneFile(event, file, cwd, mode);\n }\n emitAllow();\n}\n\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction handleOneFile(event: AgentHookEvent, file: FileOperation, cwd: string, mode: HookMode): void {\n const input = file.input;\n\n // Always allow edits to webpieces.config.json — it's the fix target when the config is broken.\n // This returns BEFORE run(), so feature-branch-guard never sees a config edit; record that so the\n // audit trail explains why a config edit on a bad branch was not blocked (see decision-log.ts).\n if (path.basename(input.filePath) === CONFIG_FILENAME) {\n if (mode !== 'rules') {\n // `.webpieces/` (the decision log + sync cache these two calls write) lives at the repo\n // root, not the AI's cwd — resolve it so a config edit from a subdir doesn't create a\n // stray `<subdir>/.webpieces` tree.\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n logGuardDecision(\n root,\n new GuardDecision('feature-branch-guard', file.toolKind, input.filePath, branchForLog(root), 'ALLOW_EXEMPT', 'config-bypass (feature-branch-guard skipped)', '-', L0_FAULT_NONE, matrixL2Row('config-bypass (feature-branch-guard skipped)')),\n );\n // The guard's own refresh trigger lives inside its check(), which we skip here — so warm\n // the cache directly, otherwise a session that only edits webpieces.config.json never\n // refreshes the sync status. Fire-and-forget; never blocks the edit.\n triggerMainSyncRefresh(root, branchStateHangTimeoutFor(cwd));\n }\n return;\n }\n\n const result = run(file.toolKind, input, cwd, mode);\n if (!result) return;\n\n logRejection(file.toolKind, input, result, cwd);\n // File-tool deny → the event's kind is 'File', so denyJson omits systemMessage (the reason\n // already renders red natively for these tools). See agent-response.ts.\n emitDeny(event, result.report, blockingRule(result.report, 'file-guard'), result.fault);\n}\n\n// What a stale committed shim lets through — now a thin adapter over the ONE L0 allowlist (isAllowed in\n// ../bin/shim), not a list of its own. A stale shim must NEVER trap the actions needed to recover: the\n// original \"block everything but the cures\" version also shadowed the always-allowed\n// webpieces.config.json edit (handleFileTool) and blocked reads, so a repo that ALSO needed its config\n// fixed would deadlock — blocked from editing the one file whose edit is normally always allowed, and\n// blocked from reading it to know how.\n//\n// It used to carry its OWN narrower list (isShimCureCommand: the three shim cures only), and that\n// narrowness was a defect, not a safety property: `pnpm install` and `git pull` — the two commands that\n// resolve the version disagreement underneath a stale shim — were denied. Consulting the shared\n// allowlist fixes that by construction.\n//\n// What is NOT a defect, and must not be \"fixed\": those cures rewrite the committed shim from the\n// INSTALLED binary's renderShim(), overwriting whatever was there. That is the invariant, not\n// collateral damage. The shim (D/X/K, in POSIX sh, pre-binary) and this binary (S/C/Y, in JS) are two\n// halves of ONE L0 and they exchange assumptions — the shim parses file_path and carries ALLOW-READ /\n// ALLOW-CONFIG entries this binary relies on. Pair a binary with a shim rendered by a DIFFERENT\n// release and L0 acquires holes that nothing reports. So the rule is absolute: the committed shim\n// equals renderShim() of the binary in node_modules, and a cure that forces that is the cure working.\n// See healShim's header, which states the same invariant from the other side.\n//\n// Corollary for anyone regenerating the shim in a webpieces PR: commit `templates/ai-hook.sh` (source,\n// locked to renderShim() by unit test) and leave `.claude/webpieces/ai-hook.sh` (generated artifact)\n// alone. In THIS repo the local source runs ahead of the pinned node_modules, so committing a shim\n// rendered from local source produces a commit whose shim and whose @webpieces pin come from different\n// releases — precisely the mismatch above. The artifact heals on the next upgrade; that is its job.\n//\n// - 'allow-cure' → a Bash cure on the allowlist: emitAllow directly, bypassing the git guards.\n// - 'pass' → a recovery action the normal flow already permits, so fall THROUGH and let it: ANY\n// Read (you must read to know how to fix — see handleRead, which itself fails open),\n// or an edit to webpieces.config.json (the always-allowed recovery target).\n// - 'deny' → all OTHER work: blocked until the committed shim matches renderShim() again.\nexport type ShimStaleDecision = 'allow-cure' | 'pass' | 'deny';\n// webpieces-disable no-function-outside-class -- pure decision helper beside the adapter's other module-scope functions; exported for direct unit testing.\nexport function shimStaleRecoveryDecision(toolName: string, command: string, filePath: string): ShimStaleDecision {\n const allowed = isAllowed(toolName, command, filePath);\n if (allowed === 'pass') return 'pass';\n if (allowed === 'allow') return 'allow-cure';\n return 'deny';\n}\n\n// MANAGED-HOOK-SURFACE self-guard, moved here from the rendered shim (2026-07-24) and widened from one\n// file to three (2026-08-07). The committed\n// .claude/webpieces/ai-hook.sh is webpieces-MANAGED and generated from renderShim(); if it no longer\n// matches, it was reverted / hand-edited / predates this binary, so its OWN fail-closed logic can't be\n// trusted. We are the CURRENT binary from node_modules — the trustworthy party — so WE decide here\n// instead of the (possibly stale) shim. It used to `cmp` itself inside the shim: a double-edged trap,\n// since the check lived in the very file it guarded and a fix could only ship by regenerating that\n// file. Now we fail closed on all real WORK while always leaving the recovery path open (see\n// shimStaleRecoveryDecision): the whole L0 allowlist, any Read, and editing webpieces.config.json. We deny +\n// tell the AI; we do NOT silently rewrite the file under it. 'rules' hook skips it (guards owns the\n// shim). Returns normally (pass / nothing to do) or exits via emitAllow/emitDeny.\n//\n// It asks the allowlist about the RAW WIRE FIELDS, not about the normalized event, and that ordering is\n// deliberate: L0 has to hold on a tree too broken to trust anything above it, including the adapters.\n// The raw fields are the same key names in both harnesses (measured), so one reading serves both, and\n// the answer cannot change because a normalizer changed. `event` is here only to decorate the deny.\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction enforceCommittedShim(payload: AgentPayload, event: AgentHookEvent, cwd: string, mode: HookMode): void {\n // ONE root for the whole decision, resolved from the RUNNING MODULE (governingShimRoot), never from\n // `cwd`: the shim file we compare and the renderShim() we compare it TO must come from the same\n // install, or the check straddles two trees and can never converge (see governingShimRoot's header).\n // `cwd` still selects where the L0 matrix doc is dropped — that is a \"where does the AI read\" question,\n // not part of the judgement.\n const shimRoot = governingShimRoot();\n if (mode === 'rules') return;\n // WHICH of the managed things moved — ai-hook.sh, either harness's hook registration\n // (.claude/settings.json, .codex/hooks.json), or the Claude settings' managed env entry (the\n // Bash-cwd pin that keeps a guard's verdict independent of where an earlier `cd` left the shell;\n // see managed-env.ts). Nothing validated the registration before it joined this\n // fault, so a settings file left on a superseded form silently changed WHO GOVERNS, with no signal\n // anywhere — which is the whole reason the registration is a drift surface and not just an install\n // step.\n const drifted = managedSurfaceDrift(shimRoot);\n if (drifted.length === 0) return;\n const decision = shimStaleRecoveryDecision(payload.tool_name, payload.tool_input.command ?? '', payload.tool_input.file_path ?? '');\n if (decision === 'pass') return;\n if (decision === 'allow-cure') emitAllow();\n // Drop the L0 matrix doc where the AI can read it and point the deny at it — a Read is entry 1 of\n // the same allowlist, so the pointer is always followable. Best-effort: no doc → no pointer.\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n const docPath = writeGuardMatrixDoc(root);\n // WRITE THE AUDIT LINE HERE. This block happens BEFORE invocationLog.begin() — it has to, since a\n // stale shim invalidates everything downstream — so emitDeny's flush finds nothing pending and an\n // `S` storm left NO trace at all: the one fault most likely to block twenty consecutive tool calls\n // was the one fault the trail could not show. A decision line is the fix that costs no reordering.\n const target = payload.tool_input.command ?? payload.tool_input.file_path ?? '';\n logGuardDecision(\n root,\n new GuardDecision('committed-shim-stale', payload.tool_name, target, branchForLog(root), 'BLOCK_AI_CURE', 'L0 fault S (committed shim != renderShim)', '-', L0_FAULT_SHIM_STALE, MATRIX_L0_BLOCK),\n );\n // L0 fault S in GUARD_MATRIX.md's codebook — named as the blocking rule so the invocation line\n // says WHAT stopped the call, not merely that something did, and stamped as `fault=S` so the same\n // grep finds it here as in the sh half's `L0-shim/` stream.\n // A subagent is discriminated by `agent_id`, which BOTH harnesses populate on stdin only off the\n // main loop (main falls back to the session id / leaves it empty). Its cure differs: the hooks\n // blocking it resolve through CLAUDE_PROJECT_DIR, which names the MAIN tree.\n const inSubagent = event.agentId !== '';\n emitDeny(event, shimStaleDenyReason(installedShimRulesVersion(), shimRoot ?? '', drifted, inSubagent) + guardMatrixPointer(docPath), 'committed-shim-stale', L0_FAULT_SHIM_STALE);\n}\n\n/**\n * THE PIPELINE, from raw stdin bytes to the decision — the whole of `parse -> adapter -> runner ->\n * emit`, as ONE function returning ONE value.\n *\n * `mode` selects which tool kinds to validate; payloads outside the mode's scope pass through\n * (emitAllow). A block is a PreToolUse `permissionDecision:\"deny\"` JSON on stdout with exit 0 — see\n * agent-response.ts. Fails CLOSED on any unexpected crash (returns a deny) so a broken hook never\n * silently lets an edit through, and the reason surfaces in the agent's UI instead of being hidden on\n * a stderr+exit-2 block.\n *\n * It reads NO stdin, writes NO stdout and calls NO exit: those three couplings are ports owned by\n * HookApp (see hook-ports.ts), which is what makes the composed pipeline drivable from a test. This\n * function is what `runMain` was; it is not a second spelling of it — `runMain` is deleted, and\n * `HookApp.run()` is the only entry point.\n */\n// webpieces-disable no-function-outside-class -- the module-scope hook body itself, sibling of handleBash()/handleFileTool(); the adapter is module-scope functions by design and must stay callable from a tree too broken to build a DI container\nexport function runPipeline(raw: string, mode: HookMode): HookOutcome {\n // Captured as soon as the ENVELOPE parses so the fail-closed catch below can tell denyJson which\n // kind of call it is denying — a crash on a Bash call still gets the visible red systemMessage, a\n // crash on a file tool does not. Null (before parse / malformed input) → treated as non-Bash.\n let event: AgentHookEvent | null = null;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const payload = new AgentPayloadParser().parse(raw);\n if (!payload) { emitAllow(); }\n // The envelope shape first: it reads only `tool_name` and the identity fields, so it cannot\n // fail, and it is what the crash path needs. The full normalization below reads `tool_input`\n // and CAN fail (a malformed Codex patch envelope denies rather than being half-understood).\n event = ADAPTERS.envelope(payload);\n\n // BEFORE enforceCommittedShim(), which can itself write a BLOCK line. See LogStream for why\n // all three of session/agent/hook are needed to keep concurrent writers off one file.\n logStream.identify(new StreamIdentity(event.sessionId, event.agentId, mode));\n // Same moment, same reason: every JS-side stream stamps `ai=`, and the fault-S line written\n // inside enforceCommittedShim() is one of them. See AiTypeContext for why the harness is a\n // process-wide holder rather than a parameter on four writers.\n aiTypeContext.identify(event.aiType);\n\n // Prefer the payload cwd (the AI's actual working dir, follows a persisted `cd`) over\n // process.cwd(); they match today, but the payload is the authoritative signal and stays\n // correct if the hook is ever invoked from a fixed dir (e.g. via $CLAUDE_PROJECT_DIR).\n const cwd = payload.cwd ?? process.cwd();\n\n // Committed-shim self-guard: blocks real work while the committed shim is stale, but keeps the\n // recovery path open (cures, reads, config edit). See enforceCommittedShim / shimStaleRecoveryDecision.\n enforceCommittedShim(payload, event, cwd, mode);\n\n event = ADAPTERS.toEvent(payload, cwd);\n\n // Read-only tools: their own fast path, which never returns when it applies.\n handleReadFastPath(event, cwd, mode);\n\n // Per-invocation guard log (the `calls/` stream): tool + command/file + live branch +\n // main-sync-status snapshot, on EVERY guards call, for later cleanup automation. Best-effort;\n // never blocks the call. (The committed shim is no longer silently healed here — a mismatch is\n // reported by the self-guard above, not rewritten out from under the AI.)\n if (mode !== 'rules') {\n invocationLog.begin(cwd, event.rawToolName, logTarget(event));\n }\n\n if (event.kind === 'Bash') {\n // No code-style rule is bash-scoped, so the rules hook ignores Bash.\n if (mode === 'rules') { emitAllow(); }\n handleBash(event, cwd, mode);\n }\n\n // File payloads run in 'rules' (code-style), 'guards' (file-scoped guards like\n // feature-branch-guard), and 'all'. The runner filters to the right category.\n handleFileTool(event, cwd, mode);\n } catch (err: unknown) {\n const error = toError(err);\n // The pipeline's own terminal control flow, not a failure: emitAllow/emitDeny threw the answer\n // out to here from wherever they were called. Treating it as a crash would turn every allow\n // into a deny, so this branch comes FIRST and returns the carried outcome verbatim.\n if (error instanceof HookTerminated) return error.outcome;\n return denyForCrash(error, event);\n }\n}\n\n// What the `calls/` audit line names as the call's target: the command for a shell call, else the first\n// file it touches. A Codex `apply_patch` touching several files names the first — the rejection log and\n// the decision log carry the rest, per file.\n// webpieces-disable no-function-outside-class -- sibling of the module-scope hook entry points in this adapter\nfunction logTarget(event: AgentHookEvent): string {\n if (event.kind === 'Bash') return event.bash === null ? '' : event.bash.command;\n return event.files.length > 0 ? event.files[0].input.filePath : '';\n}\n\n/**\n * The fail-closed boundary for anything that escaped the hook body. An escaped RuleFailError (a rule\n * that threw past the runner's per-rule catch) or an InformAiError (bad config/stdin, or a Codex patch\n * envelope this parser refuses to guess at) both carry an AI-readable message; anything else is an\n * unexpected bug. All three DENY and surface their reason, because a hook that crashed established\n * nothing and must never be read as an allow.\n *\n * It BUILDS the deny (denyOutcome) rather than throwing it (emitDeny), because it is already inside\n * the catch that the throw would land in — see HookTerminated. Same bytes either way.\n */\n// webpieces-disable no-function-outside-class -- sibling of the module-scope hook entry points in this adapter; a lone class for one terminal boundary would break the file's shape\nfunction denyForCrash(error: Error, event: AgentHookEvent | null): HookOutcome {\n if (error instanceof RuleFailError) {\n return denyOutcome(event, renderRuleFailForAi(error), 'rule-crash');\n }\n if (error instanceof InformAiError) {\n return denyOutcome(event, error.message, 'bad-config-or-stdin');\n }\n return denyOutcome(event, `[ai-hooks] hook crashed unexpectedly — failing closed: ${error.message}`, 'hook-crash');\n}\n"]}
|
|
1
|
+
{"version":3,"file":"hook-core.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/hook-core.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\n\nimport { run, runBash, runRead } from '../core/runner';\nimport { branchStateHangTimeoutFor } from '../core/main-sync-timeout';\nimport { logRejection, extractRuleNames } from '../core/rejection-log';\nimport { logGuardDecision, GuardDecision, branchForLog, invocationLog, MATRIX_L0_BLOCK, matrixL2Row } from '../core/decision-log';\nimport { triggerMainSyncRefresh } from '../core/main-sync-refresh';\nimport { CONFIG_FILENAME } from '../core/load-config';\nimport { RepoRootFinder, renderRuleFailForAi } from '@webpieces/rules-config';\nimport { NormalizedToolInput, InformAiError, RuleFailError, HookMode, BlockedResult } from '../core/types';\nimport { AgentHookEvent, AiType, FileOperation } from '../core/agent-event';\nimport { toError } from '../core/to-error';\nimport { emitDeny, emitAllow, denyOutcome } from './agent-response';\nimport { HookOutcome, HookTerminated } from './hook-outcome';\nimport { AgentPayload, AgentPayloadParser } from './agent-payload';\nimport { AgentAdapters } from './agent-adapters';\nimport { detectAiType } from './detect-ai';\nimport { CodexSubagentSharedTreeGuard, CODEX_SUBAGENT_RULE } from './codex-subagent-guard';\nimport { governingShimRoot, isAllowed, installedShimRulesVersion } from '../bin/shim';\nimport { managedSurfaceDrift } from '../bin/hook-registration';\nimport { shimStaleDenyReason } from '../bin/shim-deny-reason';\nimport { writeGuardMatrixDoc, guardMatrixPointer } from '../core/l0-matrix';\nimport { logStream, StreamIdentity } from '../core/log-stream';\nimport { aiTypeContext } from '../core/ai-type-context';\nimport { L0_FAULT_SHIM_STALE, L0_FAULT_NONE } from '../core/l0-fault-codes';\n\n// Which category of rules this hook invocation runs. The hook is split into two independently\n// installable PreToolUse hooks; each runs ONE category (the runner filters by it), and both can\n// receive file AND bash payloads:\n// - 'rules' → code-style rules (file/edit scope). Bash payloads pass through (no code rules apply).\n// - 'guards' → hookGuards section: bash git/PR guards on Bash AND file guards (feature-branch-guard)\n// on Write/Edit, PLUS a log-and-allow audit of Read. Matcher is Write|Edit|MultiEdit|Bash|Read.\n// - 'all' → both categories, used by the openclaw plugin adapter (a single before_tool_call hook).\nexport type { HookMode };\n\nconst ADAPTERS = new AgentAdapters();\nconst SUBAGENT_GUARD = new CodexSubagentSharedTreeGuard();\n\n// The rule name for a block's audit line: the FIRST rule the report cites, or `fallback` when the\n// report opens with no `[rule]` header (a hand-written guard message). Comma-joined when a report\n// cites several, so `rule=` never silently drops one.\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction blockingRule(report: string, fallback: string): string {\n const names = extractRuleNames(report);\n return names.length > 0 ? names.join(',') : fallback;\n}\n\n// webpieces-disable no-function-outside-class -- sibling of handleRead()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction handleBash(event: AgentHookEvent, cwd: string, mode: HookMode): never {\n const command = event.bash === null ? '' : event.bash.command;\n if (command.trim() === '') { emitAllow(); }\n\n // READ PARITY, and it can only ever be reached from a Codex event: the adapter leaves `reads`\n // empty for Claude Code, which has a real `Read` tool and its own fast path. A Codex read arrives\n // as `Bash` running a pager, so without this the read guard and the `calls/` audit trail see none\n // of them. The command is STILL run through the bash guards below — this adds a verdict, it never\n // replaces one.\n for (const readPath of event.reads) {\n handleRead(event, readPath, cwd, mode);\n }\n\n // The HARNESS travels with the command. Fault C and fault Y are decided inside the runner, and\n // both consult the L0 allowlist — which since the gated entry exists cannot be asked without\n // knowing who is calling. `event.aiType` is the adapter's answer, the same one that routed this\n // call to the Codex read-parity loop above.\n const result = runBash(command, cwd, mode, event.aiType);\n if (!result) { emitAllow(); }\n // NO DECISION LINE HERE. This used to write a generic `bash-guard` line because a Bash deny once\n // had no audit trail at all — but every layer now records its own: L1 into `L1-location/` with its\n // row, L2's guards into `L2-decisions/` with their rule and cache, and emitDeny below stamps the\n // call-level outcome onto `calls/`. So this was the THIRD line for one block, and the worst of the\n // three: it re-resolved the root from `cwd` via RepoRootFinder, which is not necessarily the tree\n // the guard actually judged, so a `cd`-relocated command scattered one block across two different\n // `.webpieces` directories.\n //\n // Bash deny → the event's kind is 'Bash', so denyJson adds the ANSI-red systemMessage (the only\n // field a Bash deny shows the human; permissionDecisionReason is invisible on Bash). See\n // agent-response.ts.\n emitDeny(event, result.report, blockingRule(result.report, 'bash-guard'), result.fault);\n}\n\n/**\n * The read-scoped guard pass. Returns normally to ALLOW; only calls emitDeny when the guard fires.\n *\n * Wrapped in its own catch that swallows into an allow. Every other path in this hook fails CLOSED,\n * and that is right for edits and shell commands — but a crash here would block the agent from\n * READING, which includes reading webpieces.config.json to turn the offending guard off. So this one\n * path deliberately inverts the policy: a broken read-guard degrades to a no-op, never to a wedge.\n */\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction handleRead(event: AgentHookEvent, filePath: string, cwd: string, mode: HookMode): void {\n if (filePath === '') return;\n let result: BlockedResult | null = null;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n result = runRead(filePath, cwd, mode);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return; // fail OPEN — see the doc comment\n }\n if (!result) return;\n logRejection('Read', new NormalizedToolInput(filePath, []), result, cwd);\n emitDeny(event, result.report, blockingRule(result.report, 'read-guard'), result.fault);\n}\n\n/**\n * Read-only tools (Read): audit-log, warm the main-sync cache, then run the ONE read-scoped guard\n * (read-stale-guard) and allow. Runs BEFORE the general rule engine — no code-style rule ever sees a\n * Read, and the only way this path can deny is a stale `main`. The audit trail still records every\n * file the AI opened (see setup.ts).\n *\n * Returns normally ONLY when the event is not a Read; otherwise it ends the invocation.\n */\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction handleReadFastPath(event: AgentHookEvent, cwd: string, mode: HookMode): void {\n if (event.kind !== 'Read') return;\n const readPath = event.reads.length > 0 ? event.reads[0] : '';\n if (mode !== 'rules') {\n invocationLog.begin(cwd, event.rawToolName, readPath);\n // Reads vastly outnumber edits, so refreshing here is what actually keeps the shared\n // main-sync cache warm for feature-branch-guard. Detached; never slows the read.\n triggerMainSyncRefresh(cwd, branchStateHangTimeoutFor(cwd));\n }\n handleRead(event, readPath, cwd, mode);\n emitAllow();\n}\n\n/**\n * The file/edit pipeline, run once per file the call touches.\n *\n * `event.files` is a LIST because ONE Codex `apply_patch` carries many files with mixed operations.\n * A Claude Code event always has exactly one entry, so the loop runs once and the behaviour is the\n * single-file behaviour it has always had.\n */\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleRead() in this module; the adapter is module-scope functions by design\nfunction handleFileTool(event: AgentHookEvent, cwd: string, mode: HookMode): never {\n if (event.files.length === 0) { emitAllow(); }\n\n // A Codex SUBAGENT writing into the tree it shares with its coordinator. Returns null for every\n // Claude Code event — that harness can hand a subagent its own worktree, and does.\n const subagentBlock = SUBAGENT_GUARD.check(event, new RepoRootFinder().resolveRepoRoot(cwd));\n if (subagentBlock) {\n emitDeny(event, subagentBlock.report, CODEX_SUBAGENT_RULE, subagentBlock.fault);\n }\n\n for (const file of event.files) {\n handleOneFile(event, file, cwd, mode);\n }\n emitAllow();\n}\n\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction handleOneFile(event: AgentHookEvent, file: FileOperation, cwd: string, mode: HookMode): void {\n const input = file.input;\n\n // Always allow edits to webpieces.config.json — it's the fix target when the config is broken.\n // This returns BEFORE run(), so feature-branch-guard never sees a config edit; record that so the\n // audit trail explains why a config edit on a bad branch was not blocked (see decision-log.ts).\n if (path.basename(input.filePath) === CONFIG_FILENAME) {\n if (mode !== 'rules') {\n // `.webpieces/` (the decision log + sync cache these two calls write) lives at the repo\n // root, not the AI's cwd — resolve it so a config edit from a subdir doesn't create a\n // stray `<subdir>/.webpieces` tree.\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n logGuardDecision(\n root,\n new GuardDecision('feature-branch-guard', file.toolKind, input.filePath, branchForLog(root), 'ALLOW_EXEMPT', 'config-bypass (feature-branch-guard skipped)', '-', L0_FAULT_NONE, matrixL2Row('config-bypass (feature-branch-guard skipped)')),\n );\n // The guard's own refresh trigger lives inside its check(), which we skip here — so warm\n // the cache directly, otherwise a session that only edits webpieces.config.json never\n // refreshes the sync status. Fire-and-forget; never blocks the edit.\n triggerMainSyncRefresh(root, branchStateHangTimeoutFor(cwd));\n }\n return;\n }\n\n const result = run(file.toolKind, input, cwd, mode);\n if (!result) return;\n\n logRejection(file.toolKind, input, result, cwd);\n // File-tool deny → the event's kind is 'File', so denyJson omits systemMessage (the reason\n // already renders red natively for these tools). See agent-response.ts.\n emitDeny(event, result.report, blockingRule(result.report, 'file-guard'), result.fault);\n}\n\n// What a stale committed shim lets through — now a thin adapter over the ONE L0 allowlist (isAllowed in\n// ../bin/shim), not a list of its own. A stale shim must NEVER trap the actions needed to recover: the\n// original \"block everything but the cures\" version also shadowed the always-allowed\n// webpieces.config.json edit (handleFileTool) and blocked reads, so a repo that ALSO needed its config\n// fixed would deadlock — blocked from editing the one file whose edit is normally always allowed, and\n// blocked from reading it to know how.\n//\n// It used to carry its OWN narrower list (isShimCureCommand: the three shim cures only), and that\n// narrowness was a defect, not a safety property: `pnpm install` and `git pull` — the two commands that\n// resolve the version disagreement underneath a stale shim — were denied. Consulting the shared\n// allowlist fixes that by construction.\n//\n// What is NOT a defect, and must not be \"fixed\": those cures rewrite the committed shim from the\n// INSTALLED binary's renderShim(), overwriting whatever was there. That is the invariant, not\n// collateral damage. The shim (D/X/K, in POSIX sh, pre-binary) and this binary (S/C/Y, in JS) are two\n// halves of ONE L0 and they exchange assumptions — the shim parses file_path and carries ALLOW-READ /\n// ALLOW-CONFIG entries this binary relies on. Pair a binary with a shim rendered by a DIFFERENT\n// release and L0 acquires holes that nothing reports. So the rule is absolute: the committed shim\n// equals renderShim() of the binary in node_modules, and a cure that forces that is the cure working.\n// See healShim's header, which states the same invariant from the other side.\n//\n// Corollary for anyone regenerating the shim in a webpieces PR: commit `templates/ai-hook.sh` (source,\n// locked to renderShim() by unit test) and leave `.claude/webpieces/ai-hook.sh` (generated artifact)\n// alone. In THIS repo the local source runs ahead of the pinned node_modules, so committing a shim\n// rendered from local source produces a commit whose shim and whose @webpieces pin come from different\n// releases — precisely the mismatch above. The artifact heals on the next upgrade; that is its job.\n//\n// - 'allow-cure' → a Bash cure on the allowlist: emitAllow directly, bypassing the git guards.\n// - 'pass' → a recovery action the normal flow already permits, so fall THROUGH and let it: ANY\n// Read (you must read to know how to fix — see handleRead, which itself fails open),\n// or an edit to webpieces.config.json (the always-allowed recovery target).\n// - 'deny' → all OTHER work: blocked until the committed shim matches renderShim() again.\nexport type ShimStaleDecision = 'allow-cure' | 'pass' | 'deny';\n// webpieces-disable no-function-outside-class -- pure decision helper beside the adapter's other module-scope functions; exported for direct unit testing.\nexport function shimStaleRecoveryDecision(toolName: string, command: string, filePath: string, aiType: AiType): ShimStaleDecision {\n const allowed = isAllowed(toolName, command, filePath, aiType);\n if (allowed === 'pass') return 'pass';\n if (allowed === 'allow') return 'allow-cure';\n return 'deny';\n}\n\n// MANAGED-HOOK-SURFACE self-guard, moved here from the rendered shim (2026-07-24) and widened from one\n// file to three (2026-08-07). The committed\n// .claude/webpieces/ai-hook.sh is webpieces-MANAGED and generated from renderShim(); if it no longer\n// matches, it was reverted / hand-edited / predates this binary, so its OWN fail-closed logic can't be\n// trusted. We are the CURRENT binary from node_modules — the trustworthy party — so WE decide here\n// instead of the (possibly stale) shim. It used to `cmp` itself inside the shim: a double-edged trap,\n// since the check lived in the very file it guarded and a fix could only ship by regenerating that\n// file. Now we fail closed on all real WORK while always leaving the recovery path open (see\n// shimStaleRecoveryDecision): the whole L0 allowlist, any Read, and editing webpieces.config.json. We deny +\n// tell the AI; we do NOT silently rewrite the file under it. 'rules' hook skips it (guards owns the\n// shim). Returns normally (pass / nothing to do) or exits via emitAllow/emitDeny.\n//\n// It asks the allowlist about the RAW WIRE FIELDS, not about the normalized event, and that ordering is\n// deliberate: L0 has to hold on a tree too broken to trust anything above it, including the adapters.\n// The raw fields are the same key names in both harnesses (measured), so one reading serves both, and\n// the answer cannot change because a normalizer changed. `event` is here only to decorate the deny.\n// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design\nfunction enforceCommittedShim(payload: AgentPayload, event: AgentHookEvent, cwd: string, mode: HookMode): void {\n // ONE root for the whole decision, resolved from the RUNNING MODULE (governingShimRoot), never from\n // `cwd`: the shim file we compare and the renderShim() we compare it TO must come from the same\n // install, or the check straddles two trees and can never converge (see governingShimRoot's header).\n // `cwd` still selects where the L0 matrix doc is dropped — that is a \"where does the AI read\" question,\n // not part of the judgement.\n const shimRoot = governingShimRoot();\n if (mode === 'rules') return;\n // WHICH of the managed things moved — ai-hook.sh, either harness's hook registration\n // (.claude/settings.json, .codex/hooks.json), or the Claude settings' managed env entry (the\n // Bash-cwd pin that keeps a guard's verdict independent of where an earlier `cd` left the shell;\n // see managed-env.ts). Nothing validated the registration before it joined this\n // fault, so a settings file left on a superseded form silently changed WHO GOVERNS, with no signal\n // anywhere — which is the whole reason the registration is a drift surface and not just an install\n // step.\n const drifted = managedSurfaceDrift(shimRoot);\n if (drifted.length === 0) return;\n // The harness comes from the RAW envelope too — `detectAiType` asks one question of one key, so it\n // is as trustworthy here as the wire fields beside it, and it is the PRECISE test the shim's sh half\n // can only approximate. Reading it off the normalized event instead would put a normalizer between\n // L0 and its decision, which is the coupling the paragraph above exists to refuse.\n const decision = shimStaleRecoveryDecision(\n payload.tool_name, payload.tool_input.command ?? '', payload.tool_input.file_path ?? '',\n detectAiType(payload));\n if (decision === 'pass') return;\n if (decision === 'allow-cure') emitAllow();\n // Drop the L0 matrix doc where the AI can read it and point the deny at it — a Read is entry 1 of\n // the same allowlist, so the pointer is always followable. Best-effort: no doc → no pointer.\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n const docPath = writeGuardMatrixDoc(root);\n // WRITE THE AUDIT LINE HERE. This block happens BEFORE invocationLog.begin() — it has to, since a\n // stale shim invalidates everything downstream — so emitDeny's flush finds nothing pending and an\n // `S` storm left NO trace at all: the one fault most likely to block twenty consecutive tool calls\n // was the one fault the trail could not show. A decision line is the fix that costs no reordering.\n const target = payload.tool_input.command ?? payload.tool_input.file_path ?? '';\n logGuardDecision(\n root,\n new GuardDecision('committed-shim-stale', payload.tool_name, target, branchForLog(root), 'BLOCK_AI_CURE', 'L0 fault S (committed shim != renderShim)', '-', L0_FAULT_SHIM_STALE, MATRIX_L0_BLOCK),\n );\n // L0 fault S in GUARD_MATRIX.md's codebook — named as the blocking rule so the invocation line\n // says WHAT stopped the call, not merely that something did, and stamped as `fault=S` so the same\n // grep finds it here as in the sh half's `L0-shim/` stream.\n // A subagent is discriminated by `agent_id`, which BOTH harnesses populate on stdin only off the\n // main loop (main falls back to the session id / leaves it empty). Its cure differs: the hooks\n // blocking it resolve through CLAUDE_PROJECT_DIR, which names the MAIN tree.\n const inSubagent = event.agentId !== '';\n emitDeny(event, shimStaleDenyReason(installedShimRulesVersion(), shimRoot ?? '', drifted, inSubagent) + guardMatrixPointer(docPath), 'committed-shim-stale', L0_FAULT_SHIM_STALE);\n}\n\n/**\n * THE PIPELINE, from raw stdin bytes to the decision — the whole of `parse -> adapter -> runner ->\n * emit`, as ONE function returning ONE value.\n *\n * `mode` selects which tool kinds to validate; payloads outside the mode's scope pass through\n * (emitAllow). A block is a PreToolUse `permissionDecision:\"deny\"` JSON on stdout with exit 0 — see\n * agent-response.ts. Fails CLOSED on any unexpected crash (returns a deny) so a broken hook never\n * silently lets an edit through, and the reason surfaces in the agent's UI instead of being hidden on\n * a stderr+exit-2 block.\n *\n * It reads NO stdin, writes NO stdout and calls NO exit: those three couplings are ports owned by\n * HookApp (see hook-ports.ts), which is what makes the composed pipeline drivable from a test. This\n * function is what `runMain` was; it is not a second spelling of it — `runMain` is deleted, and\n * `HookApp.run()` is the only entry point.\n */\n// webpieces-disable no-function-outside-class -- the module-scope hook body itself, sibling of handleBash()/handleFileTool(); the adapter is module-scope functions by design and must stay callable from a tree too broken to build a DI container\nexport function runPipeline(raw: string, mode: HookMode): HookOutcome {\n // Captured as soon as the ENVELOPE parses so the fail-closed catch below can tell denyJson which\n // kind of call it is denying — a crash on a Bash call still gets the visible red systemMessage, a\n // crash on a file tool does not. Null (before parse / malformed input) → treated as non-Bash.\n let event: AgentHookEvent | null = null;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const payload = new AgentPayloadParser().parse(raw);\n if (!payload) { emitAllow(); }\n // The envelope shape first: it reads only `tool_name` and the identity fields, so it cannot\n // fail, and it is what the crash path needs. The full normalization below reads `tool_input`\n // and CAN fail (a malformed Codex patch envelope denies rather than being half-understood).\n event = ADAPTERS.envelope(payload);\n\n // BEFORE enforceCommittedShim(), which can itself write a BLOCK line. See LogStream for why\n // all three of session/agent/hook are needed to keep concurrent writers off one file.\n logStream.identify(new StreamIdentity(event.sessionId, event.agentId, mode));\n // Same moment, same reason: every JS-side stream stamps `ai=`, and the fault-S line written\n // inside enforceCommittedShim() is one of them. See AiTypeContext for why the harness is a\n // process-wide holder rather than a parameter on four writers.\n aiTypeContext.identify(event.aiType);\n\n // Prefer the payload cwd (the AI's actual working dir, follows a persisted `cd`) over\n // process.cwd(); they match today, but the payload is the authoritative signal and stays\n // correct if the hook is ever invoked from a fixed dir (e.g. via $CLAUDE_PROJECT_DIR).\n const cwd = payload.cwd ?? process.cwd();\n\n // Committed-shim self-guard: blocks real work while the committed shim is stale, but keeps the\n // recovery path open (cures, reads, config edit). See enforceCommittedShim / shimStaleRecoveryDecision.\n enforceCommittedShim(payload, event, cwd, mode);\n\n event = ADAPTERS.toEvent(payload, cwd);\n\n // Read-only tools: their own fast path, which never returns when it applies.\n handleReadFastPath(event, cwd, mode);\n\n // Per-invocation guard log (the `calls/` stream): tool + command/file + live branch +\n // main-sync-status snapshot, on EVERY guards call, for later cleanup automation. Best-effort;\n // never blocks the call. (The committed shim is no longer silently healed here — a mismatch is\n // reported by the self-guard above, not rewritten out from under the AI.)\n if (mode !== 'rules') {\n invocationLog.begin(cwd, event.rawToolName, logTarget(event));\n }\n\n if (event.kind === 'Bash') {\n // No code-style rule is bash-scoped, so the rules hook ignores Bash.\n if (mode === 'rules') { emitAllow(); }\n handleBash(event, cwd, mode);\n }\n\n // File payloads run in 'rules' (code-style), 'guards' (file-scoped guards like\n // feature-branch-guard), and 'all'. The runner filters to the right category.\n handleFileTool(event, cwd, mode);\n } catch (err: unknown) {\n const error = toError(err);\n // The pipeline's own terminal control flow, not a failure: emitAllow/emitDeny threw the answer\n // out to here from wherever they were called. Treating it as a crash would turn every allow\n // into a deny, so this branch comes FIRST and returns the carried outcome verbatim.\n if (error instanceof HookTerminated) return error.outcome;\n return denyForCrash(error, event);\n }\n}\n\n// What the `calls/` audit line names as the call's target: the command for a shell call, else the first\n// file it touches. A Codex `apply_patch` touching several files names the first — the rejection log and\n// the decision log carry the rest, per file.\n// webpieces-disable no-function-outside-class -- sibling of the module-scope hook entry points in this adapter\nfunction logTarget(event: AgentHookEvent): string {\n if (event.kind === 'Bash') return event.bash === null ? '' : event.bash.command;\n return event.files.length > 0 ? event.files[0].input.filePath : '';\n}\n\n/**\n * The fail-closed boundary for anything that escaped the hook body. An escaped RuleFailError (a rule\n * that threw past the runner's per-rule catch) or an InformAiError (bad config/stdin, or a Codex patch\n * envelope this parser refuses to guess at) both carry an AI-readable message; anything else is an\n * unexpected bug. All three DENY and surface their reason, because a hook that crashed established\n * nothing and must never be read as an allow.\n *\n * It BUILDS the deny (denyOutcome) rather than throwing it (emitDeny), because it is already inside\n * the catch that the throw would land in — see HookTerminated. Same bytes either way.\n */\n// webpieces-disable no-function-outside-class -- sibling of the module-scope hook entry points in this adapter; a lone class for one terminal boundary would break the file's shape\nfunction denyForCrash(error: Error, event: AgentHookEvent | null): HookOutcome {\n if (error instanceof RuleFailError) {\n return denyOutcome(event, renderRuleFailForAi(error), 'rule-crash');\n }\n if (error instanceof InformAiError) {\n return denyOutcome(event, error.message, 'bad-config-or-stdin');\n }\n return denyOutcome(event, `[ai-hooks] hook crashed unexpectedly — failing closed: ${error.message}`, 'hook-crash');\n}\n"]}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AiType } from '../core/agent-event';
|
|
1
2
|
export declare const WORKSPACE_MANIFEST = "pnpm-workspace.yaml";
|
|
2
3
|
export declare const PACKAGE_MANIFEST = "package.json";
|
|
3
4
|
export declare const MANIFEST_FILENAMES: ReadonlySet<string>;
|
|
@@ -75,6 +76,17 @@ export declare class L0AllowEntry {
|
|
|
75
76
|
readonly cure: boolean;
|
|
76
77
|
readonly ere: string | null;
|
|
77
78
|
readonly js: string | null;
|
|
79
|
+
/**
|
|
80
|
+
* The ONE harness this entry exists for, or null for the entries every harness gets.
|
|
81
|
+
*
|
|
82
|
+
* A gated entry is unreachable from any other harness — it is spliced into its own union, which
|
|
83
|
+
* both halves of L0 consult only after answering "which harness sent this call?". It exists
|
|
84
|
+
* because the two harnesses do not have the same TOOLS: `Read` is entry 1 for Claude Code and
|
|
85
|
+
* Codex has no such tool, so read parity at L0 can only be expressed per harness. Anything added
|
|
86
|
+
* here later must satisfy the property stated on AI_TYPE_SH: the sh half's answer is an
|
|
87
|
+
* approximation, so a gated entry may never grant more than the OTHER harness already has.
|
|
88
|
+
*/
|
|
89
|
+
readonly aiType: AiType | null;
|
|
78
90
|
readonly sample: L0Call;
|
|
79
91
|
readonly extraSamples: readonly L0Call[];
|
|
80
92
|
constructor(label: string, kind: 'pass' | 'allow',
|
|
@@ -84,7 +96,18 @@ export declare class L0AllowEntry {
|
|
|
84
96
|
* non-cure (read-only orientation) is allowed while a fault is up and is otherwise an ordinary
|
|
85
97
|
* command the downstream guards still judge.
|
|
86
98
|
*/
|
|
87
|
-
cure: boolean, ere: string | null, js: string | null,
|
|
99
|
+
cure: boolean, ere: string | null, js: string | null,
|
|
100
|
+
/**
|
|
101
|
+
* The ONE harness this entry exists for, or null for the entries every harness gets.
|
|
102
|
+
*
|
|
103
|
+
* A gated entry is unreachable from any other harness — it is spliced into its own union, which
|
|
104
|
+
* both halves of L0 consult only after answering "which harness sent this call?". It exists
|
|
105
|
+
* because the two harnesses do not have the same TOOLS: `Read` is entry 1 for Claude Code and
|
|
106
|
+
* Codex has no such tool, so read parity at L0 can only be expressed per harness. Anything added
|
|
107
|
+
* here later must satisfy the property stated on AI_TYPE_SH: the sh half's answer is an
|
|
108
|
+
* approximation, so a gated entry may never grant more than the OTHER harness already has.
|
|
109
|
+
*/
|
|
110
|
+
aiType: AiType | null, sample: L0Call, extraSamples?: readonly L0Call[]);
|
|
88
111
|
/** Every call this entry pins: the canonical sample plus every extra spelling. */
|
|
89
112
|
allSamples(): readonly L0Call[];
|
|
90
113
|
}
|