@webpieces/ai-hook-rules 0.4.703 → 0.4.704
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -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 +4 -0
- package/src/adapters/{claude-code-response.js → agent-response.js} +26 -11
- 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/hook-core.d.ts +5 -5
- package/src/adapters/hook-core.js +117 -113
- package/src/adapters/hook-core.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
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AI_TYPE_SH = exports.AI_TYPE_TOKEN_SH = void 0;
|
|
4
|
+
exports.detectAiType = detectAiType;
|
|
5
|
+
/**
|
|
6
|
+
* THE discriminator, and the only one. Codex's PreToolUse envelope carries a REQUIRED `turn_id`;
|
|
7
|
+
* Claude Code's has no such key. Everything else in the two envelopes is the same key names
|
|
8
|
+
* (`hook_event_name`, `tool_name`, `tool_input`, `cwd`, `session_id`, `transcript_path`), which is
|
|
9
|
+
* exactly why one positive key is the whole test rather than a shape heuristic.
|
|
10
|
+
*
|
|
11
|
+
* Exported as a TWIN — an sh fragment and a JS predicate — because L0 has two halves that must
|
|
12
|
+
* answer the identical question: the rendered POSIX-sh shim (which has no JSON parser and scrapes
|
|
13
|
+
* text) and this binary (which has the parsed object). That is the same pattern
|
|
14
|
+
* ../bin/l0-allowlist.ts already uses for `L0_ALLOW_ERE_SH` / `L0_ALLOW_JS`, and detect-ai.spec.ts
|
|
15
|
+
* asserts the two agree over a corpus the same way.
|
|
16
|
+
*
|
|
17
|
+
* The sh half is an APPROXIMATION and says so out loud: it matches the six bytes `"turn_id":` in the
|
|
18
|
+
* raw payload, so a Claude payload that happened to embed that exact quoted-key-with-colon spelling
|
|
19
|
+
* inside a string value would be misread as Codex. Matching a JSON key from sh without a JSON parser
|
|
20
|
+
* cannot do better, the spelling is contrived (an agent grepping for `turn_id` types it bare), and
|
|
21
|
+
* the consequence of the miss is bounded: the Codex path is a SUPERSET of guards, never fewer.
|
|
22
|
+
*
|
|
23
|
+
* NOT WIRED INTO THE RENDERED SHIM IN THIS CHANGE. `committedShimStale()` compares the committed
|
|
24
|
+
* `.claude/webpieces/ai-hook.sh` against `renderShim()` of the INSTALLED release, so changing the
|
|
25
|
+
* renderer and regenerating the artifact together makes L0 fault S fire for everyone mid-upgrade.
|
|
26
|
+
* The constant ships here first; the shim consumes it a release later.
|
|
27
|
+
*/
|
|
28
|
+
exports.AI_TYPE_TOKEN_SH = '"turn_id":';
|
|
29
|
+
/**
|
|
30
|
+
* Sets `AI` to the literal `AiType` value — `codex` or `claude-code` — from `$PAYLOAD`. The values
|
|
31
|
+
* are the SAME strings the TypeScript union carries, so the twin test can compare them byte for byte
|
|
32
|
+
* instead of translating between two vocabularies (translation is where twins drift).
|
|
33
|
+
*/
|
|
34
|
+
exports.AI_TYPE_SH = `case "$PAYLOAD" in *'${exports.AI_TYPE_TOKEN_SH}'*) AI=codex ;; *) AI=claude-code ;; esac`;
|
|
35
|
+
/**
|
|
36
|
+
* JS twin of AI_TYPE_SH. Asks the precise question the sh half approximates: is `turn_id` a key of
|
|
37
|
+
* the top-level envelope?
|
|
38
|
+
*/
|
|
39
|
+
// 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
|
|
40
|
+
// 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()
|
|
41
|
+
function detectAiType(payload) {
|
|
42
|
+
if (payload === null || typeof payload !== 'object')
|
|
43
|
+
return 'claude-code';
|
|
44
|
+
// webpieces-disable no-any-unknown -- narrowing the same unparsed JSON; the index signature is the widest true statement about it
|
|
45
|
+
return 'turn_id' in payload ? 'codex' : 'claude-code';
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=detect-ai.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"detect-ai.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/detect-ai.ts"],"names":[],"mappings":";;;AAwCA,oCAIC;AA1CD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACU,QAAA,gBAAgB,GAAG,YAAY,CAAC;AAE7C;;;;GAIG;AACU,QAAA,UAAU,GAAG,wBAAwB,wBAAgB,2CAA2C,CAAC;AAE9G;;;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 says so out loud: it matches the six bytes `\"turn_id\":` in the\n * raw payload, so a Claude payload that happened to embed that exact quoted-key-with-colon spelling\n * inside a string value would be misread as Codex. Matching a JSON key from sh without a JSON parser\n * cannot do better, the spelling is contrived (an agent grepping for `turn_id` types it bare), and\n * the consequence of the miss is bounded: the Codex path is a SUPERSET of guards, never fewer.\n *\n * NOT WIRED INTO THE RENDERED SHIM IN THIS CHANGE. `committedShimStale()` compares the committed\n * `.claude/webpieces/ai-hook.sh` against `renderShim()` of the INSTALLED release, so changing the\n * renderer and regenerating the artifact together makes L0 fault S fire for everyone mid-upgrade.\n * The constant ships here first; the shim consumes it a release later.\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 */\nexport const AI_TYPE_SH = `case \"$PAYLOAD\" 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"]}
|
|
@@ -3,10 +3,10 @@ export type { HookMode };
|
|
|
3
3
|
export type ShimStaleDecision = 'allow-cure' | 'pass' | 'deny';
|
|
4
4
|
export declare function shimStaleRecoveryDecision(toolName: string, command: string, filePath: string): ShimStaleDecision;
|
|
5
5
|
/**
|
|
6
|
-
* Shared entry point for
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* Shared entry point for every PreToolUse adapter. `mode` selects which tool kinds to validate;
|
|
7
|
+
* payloads outside the mode's scope pass through (emitAllow). Blocks by emitting a PreToolUse
|
|
8
|
+
* `permissionDecision:"deny"` JSON on stdout (exit 0) — see agent-response.ts. Fails CLOSED on any
|
|
9
|
+
* unexpected crash (emits a deny) so a broken hook never silently lets an edit through, and the reason
|
|
10
|
+
* surfaces in the agent's UI instead of being hidden on a stderr+exit-2 block.
|
|
11
11
|
*/
|
|
12
12
|
export declare function runMain(mode: HookMode): Promise<void>;
|
|
@@ -13,20 +13,18 @@ const load_config_1 = require("../core/load-config");
|
|
|
13
13
|
const rules_config_1 = require("@webpieces/rules-config");
|
|
14
14
|
const types_1 = require("../core/types");
|
|
15
15
|
const to_error_1 = require("../core/to-error");
|
|
16
|
-
const
|
|
16
|
+
const agent_response_1 = require("./agent-response");
|
|
17
|
+
const agent_payload_1 = require("./agent-payload");
|
|
18
|
+
const agent_adapters_1 = require("./agent-adapters");
|
|
19
|
+
const codex_subagent_guard_1 = require("./codex-subagent-guard");
|
|
17
20
|
const shim_1 = require("../bin/shim");
|
|
18
21
|
const shim_deny_reason_1 = require("../bin/shim-deny-reason");
|
|
19
22
|
const hook_registration_1 = require("../bin/hook-registration");
|
|
20
23
|
const l0_matrix_1 = require("../core/l0-matrix");
|
|
21
24
|
const log_stream_1 = require("../core/log-stream");
|
|
22
25
|
const l0_fault_codes_1 = require("../core/l0-fault-codes");
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
// (the `calls/` stream). When the guards matcher includes these (see setup.ts GUARDS_HOOK), a
|
|
26
|
-
// log-and-allow fast path records every file the AI opens — so a human can later inspect whether it
|
|
27
|
-
// read a project's design.json BEFORE editing the project. Never blocked. Scoped to Read for now;
|
|
28
|
-
// widen (Grep/Glob/NotebookRead) later if desired.
|
|
29
|
-
const READ_ONLY_TOOLS = new Set(['Read']);
|
|
26
|
+
const ADAPTERS = new agent_adapters_1.AgentAdapters();
|
|
27
|
+
const SUBAGENT_GUARD = new codex_subagent_guard_1.CodexSubagentSharedTreeGuard();
|
|
30
28
|
function readStdin() {
|
|
31
29
|
return new Promise((resolve) => {
|
|
32
30
|
let data = '';
|
|
@@ -38,44 +36,6 @@ function readStdin() {
|
|
|
38
36
|
resolve('');
|
|
39
37
|
});
|
|
40
38
|
}
|
|
41
|
-
function safeParse(raw) {
|
|
42
|
-
if (!raw || raw.trim() === '')
|
|
43
|
-
return null;
|
|
44
|
-
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
45
|
-
try {
|
|
46
|
-
return JSON.parse(raw);
|
|
47
|
-
}
|
|
48
|
-
catch (err) {
|
|
49
|
-
const error = (0, to_error_1.toError)(err);
|
|
50
|
-
throw new types_1.InformAiError(`Malformed hook input from Claude Code stdin: ${error.message}`, { cause: error });
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
function normalizeToolKind(toolName) {
|
|
54
|
-
if (HANDLED_FILE_TOOLS.has(toolName))
|
|
55
|
-
return toolName;
|
|
56
|
-
return null;
|
|
57
|
-
}
|
|
58
|
-
function normalizeToolInput(toolKind, toolInput) {
|
|
59
|
-
const filePath = toolInput.file_path;
|
|
60
|
-
if (!filePath)
|
|
61
|
-
return null;
|
|
62
|
-
if (toolKind === 'Write') {
|
|
63
|
-
return new types_1.NormalizedToolInput(filePath, [
|
|
64
|
-
new types_1.NormalizedEdit('', toolInput.content || ''),
|
|
65
|
-
]);
|
|
66
|
-
}
|
|
67
|
-
if (toolKind === 'Edit') {
|
|
68
|
-
return new types_1.NormalizedToolInput(filePath, [
|
|
69
|
-
new types_1.NormalizedEdit(toolInput.old_string || '', toolInput.new_string || ''),
|
|
70
|
-
]);
|
|
71
|
-
}
|
|
72
|
-
if (toolKind === 'MultiEdit') {
|
|
73
|
-
const raw = Array.isArray(toolInput.edits) ? toolInput.edits : [];
|
|
74
|
-
const edits = raw.map((e) => new types_1.NormalizedEdit(e.old_string || '', e.new_string || ''));
|
|
75
|
-
return new types_1.NormalizedToolInput(filePath, edits);
|
|
76
|
-
}
|
|
77
|
-
return null;
|
|
78
|
-
}
|
|
79
39
|
// The rule name for a block's audit line: the FIRST rule the report cites, or `fallback` when the
|
|
80
40
|
// report opens with no `[rule]` header (a hand-written guard message). Comma-joined when a report
|
|
81
41
|
// cites several, so `rule=` never silently drops one.
|
|
@@ -84,14 +44,23 @@ function blockingRule(report, fallback) {
|
|
|
84
44
|
const names = (0, rejection_log_1.extractRuleNames)(report);
|
|
85
45
|
return names.length > 0 ? names.join(',') : fallback;
|
|
86
46
|
}
|
|
87
|
-
function
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
47
|
+
// webpieces-disable no-function-outside-class -- sibling of handleRead()/handleFileTool() in this module; the adapter is module-scope functions by design
|
|
48
|
+
function handleBash(event, cwd, mode) {
|
|
49
|
+
const command = event.bash === null ? '' : event.bash.command;
|
|
50
|
+
if (command.trim() === '') {
|
|
51
|
+
(0, agent_response_1.emitAllow)();
|
|
52
|
+
}
|
|
53
|
+
// READ PARITY, and it can only ever be reached from a Codex event: the adapter leaves `reads`
|
|
54
|
+
// empty for Claude Code, which has a real `Read` tool and its own fast path. A Codex read arrives
|
|
55
|
+
// as `Bash` running a pager, so without this the read guard and the `calls/` audit trail see none
|
|
56
|
+
// of them. The command is STILL run through the bash guards below — this adds a verdict, it never
|
|
57
|
+
// replaces one.
|
|
58
|
+
for (const readPath of event.reads) {
|
|
59
|
+
handleRead(event, readPath, cwd, mode);
|
|
91
60
|
}
|
|
92
61
|
const result = (0, runner_1.runBash)(command, cwd, mode);
|
|
93
62
|
if (!result) {
|
|
94
|
-
(0,
|
|
63
|
+
(0, agent_response_1.emitAllow)();
|
|
95
64
|
}
|
|
96
65
|
// NO DECISION LINE HERE. This used to write a generic `bash-guard` line because a Bash deny once
|
|
97
66
|
// had no audit trail at all — but every layer now records its own: L1 into `L1-location/` with its
|
|
@@ -101,9 +70,10 @@ function handleBash(payload, cwd, mode) {
|
|
|
101
70
|
// the guard actually judged, so a `cd`-relocated command scattered one block across two different
|
|
102
71
|
// `.webpieces` directories.
|
|
103
72
|
//
|
|
104
|
-
// Bash deny →
|
|
105
|
-
// shows the human; permissionDecisionReason is invisible on Bash). See
|
|
106
|
-
|
|
73
|
+
// Bash deny → the event's kind is 'Bash', so denyJson adds the ANSI-red systemMessage (the only
|
|
74
|
+
// field a Bash deny shows the human; permissionDecisionReason is invisible on Bash). See
|
|
75
|
+
// agent-response.ts.
|
|
76
|
+
(0, agent_response_1.emitDeny)(event, result.report, blockingRule(result.report, 'bash-guard'), result.fault);
|
|
107
77
|
}
|
|
108
78
|
/**
|
|
109
79
|
* The read-scoped guard pass. Returns normally to ALLOW; only calls emitDeny when the guard fires.
|
|
@@ -114,7 +84,7 @@ function handleBash(payload, cwd, mode) {
|
|
|
114
84
|
* path deliberately inverts the policy: a broken read-guard degrades to a no-op, never to a wedge.
|
|
115
85
|
*/
|
|
116
86
|
// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design
|
|
117
|
-
function handleRead(filePath, cwd, mode) {
|
|
87
|
+
function handleRead(event, filePath, cwd, mode) {
|
|
118
88
|
if (filePath === '')
|
|
119
89
|
return;
|
|
120
90
|
let result = null;
|
|
@@ -130,19 +100,36 @@ function handleRead(filePath, cwd, mode) {
|
|
|
130
100
|
if (!result)
|
|
131
101
|
return;
|
|
132
102
|
(0, rejection_log_1.logRejection)('Read', new types_1.NormalizedToolInput(filePath, []), result, cwd);
|
|
133
|
-
(0,
|
|
103
|
+
(0, agent_response_1.emitDeny)(event, result.report, blockingRule(result.report, 'read-guard'), result.fault);
|
|
134
104
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
105
|
+
/**
|
|
106
|
+
* The file/edit pipeline, run once per file the call touches.
|
|
107
|
+
*
|
|
108
|
+
* `event.files` is a LIST because ONE Codex `apply_patch` carries many files with mixed operations.
|
|
109
|
+
* A Claude Code event always has exactly one entry, so the loop runs once and the behaviour is the
|
|
110
|
+
* single-file behaviour it has always had.
|
|
111
|
+
*/
|
|
112
|
+
// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleRead() in this module; the adapter is module-scope functions by design
|
|
113
|
+
function handleFileTool(event, cwd, mode) {
|
|
114
|
+
if (event.files.length === 0) {
|
|
115
|
+
(0, agent_response_1.emitAllow)();
|
|
139
116
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
117
|
+
// A Codex SUBAGENT writing into the tree it shares with its coordinator. Returns null for every
|
|
118
|
+
// Claude Code event — that harness can hand a subagent its own worktree, and does.
|
|
119
|
+
const subagentBlock = SUBAGENT_GUARD.check(event, new rules_config_1.RepoRootFinder().resolveRepoRoot(cwd));
|
|
120
|
+
if (subagentBlock) {
|
|
121
|
+
(0, agent_response_1.emitDeny)(event, subagentBlock.report, codex_subagent_guard_1.CODEX_SUBAGENT_RULE, subagentBlock.fault);
|
|
143
122
|
}
|
|
123
|
+
for (const file of event.files) {
|
|
124
|
+
handleOneFile(event, file, cwd, mode);
|
|
125
|
+
}
|
|
126
|
+
(0, agent_response_1.emitAllow)();
|
|
127
|
+
}
|
|
128
|
+
// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design
|
|
129
|
+
function handleOneFile(event, file, cwd, mode) {
|
|
130
|
+
const input = file.input;
|
|
144
131
|
// Always allow edits to webpieces.config.json — it's the fix target when the config is broken.
|
|
145
|
-
// This
|
|
132
|
+
// This returns BEFORE run(), so feature-branch-guard never sees a config edit; record that so the
|
|
146
133
|
// audit trail explains why a config edit on a bad branch was not blocked (see decision-log.ts).
|
|
147
134
|
if (path.basename(input.filePath) === load_config_1.CONFIG_FILENAME) {
|
|
148
135
|
if (mode !== 'rules') {
|
|
@@ -150,22 +137,21 @@ function handleFileTool(payload, cwd, mode) {
|
|
|
150
137
|
// root, not the AI's cwd — resolve it so a config edit from a subdir doesn't create a
|
|
151
138
|
// stray `<subdir>/.webpieces` tree.
|
|
152
139
|
const root = new rules_config_1.RepoRootFinder().resolveRepoRoot(cwd);
|
|
153
|
-
(0, decision_log_1.logGuardDecision)(root, new decision_log_1.GuardDecision('feature-branch-guard', toolKind, input.filePath, (0, decision_log_1.branchForLog)(root), 'ALLOW_EXEMPT', 'config-bypass (feature-branch-guard skipped)', '-', l0_fault_codes_1.L0_FAULT_NONE, (0, decision_log_1.matrixL2Row)('config-bypass (feature-branch-guard skipped)')));
|
|
140
|
+
(0, decision_log_1.logGuardDecision)(root, new decision_log_1.GuardDecision('feature-branch-guard', file.toolKind, input.filePath, (0, decision_log_1.branchForLog)(root), 'ALLOW_EXEMPT', 'config-bypass (feature-branch-guard skipped)', '-', l0_fault_codes_1.L0_FAULT_NONE, (0, decision_log_1.matrixL2Row)('config-bypass (feature-branch-guard skipped)')));
|
|
154
141
|
// The guard's own refresh trigger lives inside its check(), which we skip here — so warm
|
|
155
142
|
// the cache directly, otherwise a session that only edits webpieces.config.json never
|
|
156
143
|
// refreshes the sync status. Fire-and-forget; never blocks the edit.
|
|
157
144
|
(0, main_sync_refresh_1.triggerMainSyncRefresh)(root, (0, main_sync_timeout_1.branchStateHangTimeoutFor)(cwd));
|
|
158
145
|
}
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
const result = (0, runner_1.run)(toolKind, input, cwd, mode);
|
|
162
|
-
if (!result) {
|
|
163
|
-
(0, claude_code_response_1.emitAllow)();
|
|
146
|
+
return;
|
|
164
147
|
}
|
|
165
|
-
(0,
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
(0,
|
|
148
|
+
const result = (0, runner_1.run)(file.toolKind, input, cwd, mode);
|
|
149
|
+
if (!result)
|
|
150
|
+
return;
|
|
151
|
+
(0, rejection_log_1.logRejection)(file.toolKind, input, result, cwd);
|
|
152
|
+
// File-tool deny → the event's kind is 'File', so denyJson omits systemMessage (the reason
|
|
153
|
+
// already renders red natively for these tools). See agent-response.ts.
|
|
154
|
+
(0, agent_response_1.emitDeny)(event, result.report, blockingRule(result.report, 'file-guard'), result.fault);
|
|
169
155
|
}
|
|
170
156
|
// webpieces-disable no-function-outside-class -- pure decision helper beside the adapter's other module-scope functions; exported for direct unit testing.
|
|
171
157
|
function shimStaleRecoveryDecision(toolName, command, filePath) {
|
|
@@ -187,8 +173,13 @@ function shimStaleRecoveryDecision(toolName, command, filePath) {
|
|
|
187
173
|
// shimStaleRecoveryDecision): the whole L0 allowlist, any Read, and editing webpieces.config.json. We deny +
|
|
188
174
|
// tell the AI; we do NOT silently rewrite the file under it. 'rules' hook skips it (guards owns the
|
|
189
175
|
// shim). Returns normally (pass / nothing to do) or exits via emitAllow/emitDeny.
|
|
176
|
+
//
|
|
177
|
+
// It asks the allowlist about the RAW WIRE FIELDS, not about the normalized event, and that ordering is
|
|
178
|
+
// deliberate: L0 has to hold on a tree too broken to trust anything above it, including the adapters.
|
|
179
|
+
// The raw fields are the same key names in both harnesses (measured), so one reading serves both, and
|
|
180
|
+
// the answer cannot change because a normalizer changed. `event` is here only to decorate the deny.
|
|
190
181
|
// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design
|
|
191
|
-
function enforceCommittedShim(payload, cwd, mode) {
|
|
182
|
+
function enforceCommittedShim(payload, event, cwd, mode) {
|
|
192
183
|
// ONE root for the whole decision, resolved from the RUNNING MODULE (governingShimRoot), never from
|
|
193
184
|
// `cwd`: the shim file we compare and the renderShim() we compare it TO must come from the same
|
|
194
185
|
// install, or the check straddles two trees and can never converge (see governingShimRoot's header).
|
|
@@ -210,7 +201,7 @@ function enforceCommittedShim(payload, cwd, mode) {
|
|
|
210
201
|
if (decision === 'pass')
|
|
211
202
|
return;
|
|
212
203
|
if (decision === 'allow-cure')
|
|
213
|
-
(0,
|
|
204
|
+
(0, agent_response_1.emitAllow)();
|
|
214
205
|
// Drop the L0 matrix doc where the AI can read it and point the deny at it — a Read is entry 1 of
|
|
215
206
|
// the same allowlist, so the pointer is always followable. Best-effort: no doc → no pointer.
|
|
216
207
|
const root = new rules_config_1.RepoRootFinder().resolveRepoRoot(cwd);
|
|
@@ -224,96 +215,109 @@ function enforceCommittedShim(payload, cwd, mode) {
|
|
|
224
215
|
// L0 fault S in GUARD_MATRIX.md's codebook — named as the blocking rule so the invocation line
|
|
225
216
|
// says WHAT stopped the call, not merely that something did, and stamped as `fault=S` so the same
|
|
226
217
|
// grep finds it here as in the sh half's `L0-shim/` stream.
|
|
227
|
-
// A subagent is discriminated by `agent_id`, which
|
|
228
|
-
// loop (main falls back to the session id
|
|
218
|
+
// A subagent is discriminated by `agent_id`, which BOTH harnesses populate on stdin only off the
|
|
219
|
+
// main loop (main falls back to the session id / leaves it empty). Its cure differs: the hooks
|
|
229
220
|
// blocking it resolve through CLAUDE_PROJECT_DIR, which names the MAIN tree.
|
|
230
|
-
const inSubagent =
|
|
231
|
-
(0,
|
|
221
|
+
const inSubagent = event.agentId !== '';
|
|
222
|
+
(0, agent_response_1.emitDeny)(event, (0, shim_deny_reason_1.shimStaleDenyReason)((0, shim_1.installedShimRulesVersion)(), shimRoot ?? '', drifted, inSubagent) + (0, l0_matrix_1.guardMatrixPointer)(docPath), 'committed-shim-stale', l0_fault_codes_1.L0_FAULT_SHIM_STALE);
|
|
232
223
|
}
|
|
233
224
|
/**
|
|
234
|
-
* Shared entry point for
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
225
|
+
* Shared entry point for every PreToolUse adapter. `mode` selects which tool kinds to validate;
|
|
226
|
+
* payloads outside the mode's scope pass through (emitAllow). Blocks by emitting a PreToolUse
|
|
227
|
+
* `permissionDecision:"deny"` JSON on stdout (exit 0) — see agent-response.ts. Fails CLOSED on any
|
|
228
|
+
* unexpected crash (emits a deny) so a broken hook never silently lets an edit through, and the reason
|
|
229
|
+
* surfaces in the agent's UI instead of being hidden on a stderr+exit-2 block.
|
|
239
230
|
*/
|
|
240
231
|
async function runMain(mode) {
|
|
241
|
-
// Captured
|
|
242
|
-
//
|
|
243
|
-
// crash on a file tool does not.
|
|
244
|
-
let
|
|
232
|
+
// Captured as soon as the ENVELOPE parses so the fail-closed catch below can tell denyJson which
|
|
233
|
+
// kind of call it is denying — a crash on a Bash call still gets the visible red systemMessage, a
|
|
234
|
+
// crash on a file tool does not. Null (before parse / malformed input) → treated as non-Bash.
|
|
235
|
+
let event = null;
|
|
245
236
|
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
246
237
|
try {
|
|
247
238
|
const raw = await readStdin();
|
|
248
|
-
const payload =
|
|
239
|
+
const payload = new agent_payload_1.AgentPayloadParser().parse(raw);
|
|
249
240
|
if (!payload) {
|
|
250
|
-
(0,
|
|
241
|
+
(0, agent_response_1.emitAllow)();
|
|
251
242
|
}
|
|
252
|
-
|
|
243
|
+
// The envelope shape first: it reads only `tool_name` and the identity fields, so it cannot
|
|
244
|
+
// fail, and it is what the crash path needs. The full normalization below reads `tool_input`
|
|
245
|
+
// and CAN fail (a malformed Codex patch envelope denies rather than being half-understood).
|
|
246
|
+
event = ADAPTERS.envelope(payload);
|
|
253
247
|
// BEFORE enforceCommittedShim(), which can itself write a BLOCK line. See LogStream for why
|
|
254
248
|
// all three of session/agent/hook are needed to keep concurrent writers off one file.
|
|
255
|
-
log_stream_1.logStream.identify(new log_stream_1.StreamIdentity(
|
|
249
|
+
log_stream_1.logStream.identify(new log_stream_1.StreamIdentity(event.sessionId, event.agentId, mode));
|
|
256
250
|
// Prefer the payload cwd (the AI's actual working dir, follows a persisted `cd`) over
|
|
257
251
|
// process.cwd(); they match today, but the payload is the authoritative signal and stays
|
|
258
252
|
// correct if the hook is ever invoked from a fixed dir (e.g. via $CLAUDE_PROJECT_DIR).
|
|
259
253
|
const cwd = payload.cwd ?? process.cwd();
|
|
260
254
|
// Committed-shim self-guard: blocks real work while the committed shim is stale, but keeps the
|
|
261
255
|
// recovery path open (cures, reads, config edit). See enforceCommittedShim / shimStaleRecoveryDecision.
|
|
262
|
-
enforceCommittedShim(payload, cwd, mode);
|
|
256
|
+
enforceCommittedShim(payload, event, cwd, mode);
|
|
257
|
+
event = ADAPTERS.toEvent(payload, cwd);
|
|
263
258
|
// Read-only tools (Read): audit-log, warm the main-sync cache, then run the ONE read-scoped
|
|
264
259
|
// guard (read-stale-guard) and allow. Runs BEFORE the general rule engine — no code-style rule
|
|
265
260
|
// ever sees a Read, and the only way this path can deny is a stale `main`.
|
|
266
261
|
// The audit trail still records every file the AI opened (see setup.ts).
|
|
267
|
-
if (
|
|
268
|
-
const readPath =
|
|
262
|
+
if (event.kind === 'Read') {
|
|
263
|
+
const readPath = event.reads.length > 0 ? event.reads[0] : '';
|
|
269
264
|
if (mode !== 'rules') {
|
|
270
|
-
decision_log_1.invocationLog.begin(cwd,
|
|
265
|
+
decision_log_1.invocationLog.begin(cwd, event.rawToolName, readPath);
|
|
271
266
|
// Reads vastly outnumber edits, so refreshing here is what actually keeps the shared
|
|
272
267
|
// main-sync cache warm for feature-branch-guard. Detached; never slows the read.
|
|
273
268
|
(0, main_sync_refresh_1.triggerMainSyncRefresh)(cwd, (0, main_sync_timeout_1.branchStateHangTimeoutFor)(cwd));
|
|
274
269
|
}
|
|
275
|
-
handleRead(readPath, cwd, mode);
|
|
276
|
-
(0,
|
|
270
|
+
handleRead(event, readPath, cwd, mode);
|
|
271
|
+
(0, agent_response_1.emitAllow)();
|
|
277
272
|
}
|
|
278
273
|
// Per-invocation guard log (the `calls/` stream): tool + command/file + live branch +
|
|
279
274
|
// main-sync-status snapshot, on EVERY guards call, for later cleanup automation. Best-effort;
|
|
280
275
|
// never blocks the call. (The committed shim is no longer silently healed here — a mismatch is
|
|
281
276
|
// reported by the self-guard above, not rewritten out from under the AI.)
|
|
282
277
|
if (mode !== 'rules') {
|
|
283
|
-
|
|
284
|
-
decision_log_1.invocationLog.begin(cwd, payload.tool_name, target);
|
|
278
|
+
decision_log_1.invocationLog.begin(cwd, event.rawToolName, logTarget(event));
|
|
285
279
|
}
|
|
286
|
-
if (
|
|
280
|
+
if (event.kind === 'Bash') {
|
|
287
281
|
// No code-style rule is bash-scoped, so the rules hook ignores Bash.
|
|
288
282
|
if (mode === 'rules') {
|
|
289
|
-
(0,
|
|
283
|
+
(0, agent_response_1.emitAllow)();
|
|
290
284
|
}
|
|
291
|
-
handleBash(
|
|
285
|
+
handleBash(event, cwd, mode);
|
|
292
286
|
return;
|
|
293
287
|
}
|
|
294
288
|
// File payloads run in 'rules' (code-style), 'guards' (file-scoped guards like
|
|
295
289
|
// feature-branch-guard), and 'all'. The runner filters to the right category.
|
|
296
|
-
handleFileTool(
|
|
290
|
+
handleFileTool(event, cwd, mode);
|
|
297
291
|
}
|
|
298
292
|
catch (err) {
|
|
299
293
|
const error = (0, to_error_1.toError)(err);
|
|
300
|
-
denyForCrash(error,
|
|
294
|
+
denyForCrash(error, event);
|
|
301
295
|
}
|
|
302
296
|
}
|
|
297
|
+
// What the `calls/` audit line names as the call's target: the command for a shell call, else the first
|
|
298
|
+
// file it touches. A Codex `apply_patch` touching several files names the first — the rejection log and
|
|
299
|
+
// the decision log carry the rest, per file.
|
|
300
|
+
// webpieces-disable no-function-outside-class -- sibling of the module-scope hook entry points in this adapter
|
|
301
|
+
function logTarget(event) {
|
|
302
|
+
if (event.kind === 'Bash')
|
|
303
|
+
return event.bash === null ? '' : event.bash.command;
|
|
304
|
+
return event.files.length > 0 ? event.files[0].input.filePath : '';
|
|
305
|
+
}
|
|
303
306
|
/**
|
|
304
307
|
* The fail-closed boundary for anything that escaped the hook body. An escaped RuleFailError (a rule
|
|
305
|
-
* that threw past the runner's per-rule catch) or an InformAiError (bad config/stdin
|
|
306
|
-
* AI-readable message; anything else is an
|
|
307
|
-
*
|
|
308
|
+
* that threw past the runner's per-rule catch) or an InformAiError (bad config/stdin, or a Codex patch
|
|
309
|
+
* envelope this parser refuses to guess at) both carry an AI-readable message; anything else is an
|
|
310
|
+
* unexpected bug. All three DENY and surface their reason, because a hook that crashed established
|
|
311
|
+
* nothing and must never be read as an allow.
|
|
308
312
|
*/
|
|
309
313
|
// 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
|
|
310
|
-
function denyForCrash(error,
|
|
314
|
+
function denyForCrash(error, event) {
|
|
311
315
|
if (error instanceof types_1.RuleFailError) {
|
|
312
|
-
(0,
|
|
316
|
+
(0, agent_response_1.emitDeny)(event, (0, rules_config_1.renderRuleFailForAi)(error), 'rule-crash');
|
|
313
317
|
}
|
|
314
318
|
if (error instanceof types_1.InformAiError) {
|
|
315
|
-
(0,
|
|
319
|
+
(0, agent_response_1.emitDeny)(event, error.message, 'bad-config-or-stdin');
|
|
316
320
|
}
|
|
317
|
-
(0,
|
|
321
|
+
(0, agent_response_1.emitDeny)(event, `[ai-hooks] hook crashed unexpectedly — failing closed: ${error.message}`, 'hook-crash');
|
|
318
322
|
}
|
|
319
323
|
//# sourceMappingURL=hook-core.js.map
|