@webpieces/ai-hook-rules 0.4.703 → 0.4.705
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -2
- package/src/adapters/agent-adapters.d.ts +13 -0
- package/src/adapters/agent-adapters.js +23 -0
- package/src/adapters/agent-adapters.js.map +1 -0
- package/src/adapters/agent-payload.d.ts +48 -0
- package/src/adapters/agent-payload.js +30 -0
- package/src/adapters/agent-payload.js.map +1 -0
- package/src/adapters/agent-response.d.ts +6 -0
- package/src/adapters/{claude-code-response.js → agent-response.js} +56 -22
- package/src/adapters/agent-response.js.map +1 -0
- package/src/adapters/claude-code-adapter.d.ts +26 -0
- package/src/adapters/claude-code-adapter.js +69 -0
- package/src/adapters/claude-code-adapter.js.map +1 -0
- package/src/adapters/codex-adapter.d.ts +19 -0
- package/src/adapters/codex-adapter.js +48 -0
- package/src/adapters/codex-adapter.js.map +1 -0
- package/src/adapters/codex-subagent-guard.d.ts +30 -0
- package/src/adapters/codex-subagent-guard.js +58 -0
- package/src/adapters/codex-subagent-guard.js.map +1 -0
- package/src/adapters/detect-ai.d.ts +36 -0
- package/src/adapters/detect-ai.js +47 -0
- package/src/adapters/detect-ai.js.map +1 -0
- package/src/adapters/guards-hook.d.ts +1 -0
- package/src/adapters/guards-hook.js +21 -6
- package/src/adapters/guards-hook.js.map +1 -1
- package/src/adapters/hook-app-fixtures.d.ts +68 -0
- package/src/adapters/hook-app-fixtures.js +175 -0
- package/src/adapters/hook-app-fixtures.js.map +1 -0
- package/src/adapters/hook-app.d.ts +61 -0
- package/src/adapters/hook-app.js +110 -0
- package/src/adapters/hook-app.js.map +1 -0
- package/src/adapters/hook-core.d.ts +15 -6
- package/src/adapters/hook-core.js +157 -139
- package/src/adapters/hook-core.js.map +1 -1
- package/src/adapters/hook-outcome.d.ts +46 -0
- package/src/adapters/hook-outcome.js +60 -0
- package/src/adapters/hook-outcome.js.map +1 -0
- package/src/adapters/hook-ports.d.ts +57 -0
- package/src/adapters/hook-ports.js +90 -0
- package/src/adapters/hook-ports.js.map +1 -0
- package/src/adapters/rules-hook.d.ts +1 -0
- package/src/adapters/rules-hook.js +19 -5
- package/src/adapters/rules-hook.js.map +1 -1
- package/src/core/agent-event.d.ts +65 -0
- package/src/core/agent-event.js +59 -0
- package/src/core/agent-event.js.map +1 -0
- package/src/core/apply-patch-parse.d.ts +36 -0
- package/src/core/apply-patch-parse.js +154 -0
- package/src/core/apply-patch-parse.js.map +1 -0
- package/src/core/delete-scoped-rules.d.ts +8 -0
- package/src/core/delete-scoped-rules.js +31 -0
- package/src/core/delete-scoped-rules.js.map +1 -0
- package/src/core/runner.js +2 -1
- package/src/core/runner.js.map +1 -1
- package/src/core/shell-read-parity.d.ts +22 -0
- package/src/core/shell-read-parity.js +145 -0
- package/src/core/shell-read-parity.js.map +1 -0
- package/src/core/types.d.ts +1 -1
- package/src/core/types.js.map +1 -1
- package/src/index.d.ts +2 -0
- package/src/index.js +11 -1
- package/src/index.js.map +1 -1
- package/src/adapters/claude-code-response.d.ts +0 -3
- package/src/adapters/claude-code-response.js.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hook-app.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/hook-app.ts"],"names":[],"mappings":";;;;AAAA,yCAA2D;AAE3D,2CAA0C;AAC1C,qDAAyD;AAEzD,6CAAgF;AAChF,+CAA2C;AAE3C,kGAAkG;AAClG,uGAAuG;AACvG,aAAa;AACb,MAAM,YAAY,GAAG,yDAAyD,CAAC;AAE/E;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEI,IAAM,OAAO,GAAb,MAAM,OAAO;IACC,KAAK,CAAkB;IACvB,MAAM,CAAiB;IACvB,WAAW,CAAkB;IAE9C,YAAY,KAAsB,EAAE,MAAsB,EAAE,WAA4B;QACpF,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;QACnB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,WAAW,GAAG,WAAW,CAAC;IACnC,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,IAAc;QACpB,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;QACxC,4FAA4F;QAC5F,6FAA6F;QAC7F,6CAA6C;QAC7C,IAAI,OAAO,CAAC,MAAM,KAAK,EAAE;YAAE,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;QAC7D,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC5C,CAAC;IAED;;;;;;;;;;;;OAYG;IACK,KAAK,CAAC,MAAM,CAAC,IAAc;QAC/B,8DAA8D;QAC9D,IAAI,CAAC;YACD,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC;YACpC,OAAO,IAAA,uBAAW,EAAC,GAAG,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC;QACvC,CAAC;QAAC,OAAO,GAAY,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;YAC3B,OAAO,IAAA,4BAAW,EAAC,IAAI,EAAE,GAAG,YAAY,GAAG,KAAK,CAAC,OAAO,EAAE,EAAE,YAAY,CAAC,CAAC;QAC9E,CAAC;IACL,CAAC;CACJ,CAAA;AA3CY,0BAAO;kBAAP,OAAO;IADnB,IAAA,sBAAU,EAAC,8BAAkB,CAAC,SAAS,CAAC;6CAMlB,4BAAe,EAAU,2BAAc,EAAe,4BAAe;GAL/E,OAAO,CA2CnB;AAED;;;;;;;;;;;;GAYG;AACH,MAAa,eAAe;IACxB,8IAA8I;IAC9I,MAAM,CAAC,GAAY;QACf,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;QAC3B,8FAA8F;QAC9F,qFAAqF;QACrF,sPAAsP;QACtP,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,IAAA,yBAAQ,EAAC,IAAI,EAAE,GAAG,YAAY,GAAG,KAAK,CAAC,OAAO,EAAE,CAAC,GAAG,IAAI,CAAC,CAAC;QAC/E,mLAAmL;QACnL,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IACpB,CAAC;CACJ;AAXD,0CAWC","sourcesContent":["import { injectable, bindingScopeValues } from 'inversify';\n\nimport { runPipeline } from './hook-core';\nimport { denyJson, denyOutcome } from './agent-response';\nimport { HookArgs, HookOutcome } from './hook-outcome';\nimport { HookStdinSource, HookStdoutSink, HookProcessExit } from './hook-ports';\nimport { toError } from '../core/to-error';\n\n// The reason text a crash surfaces to the agent. ONE literal, used by both fail-closed boundaries\n// below and worded identically to `denyForCrash`'s so the audit trail reads the same whichever of them\n// caught it.\nconst CRASH_PREFIX = '[ai-hooks] hook crashed unexpectedly — failing closed: ';\n\n/**\n * THE COMPOSITION ROOT'S APP — one PreToolUse invocation, end to end.\n *\n * Production is three lines in `guards-hook.ts` / `rules-hook.ts`:\n *\n * const container = new Container({ autobind: true });\n * const app = container.get(HookApp);\n * await app.run(new HookArgs('guards'));\n *\n * and a test is the SAME three lines with the ports rebound to doubles — canned stdin, a captured\n * stdout, a recorded exit code. That is the whole difference, and it is the point: the test boundary\n * is cut JUST ABOVE the injection point, so the seam a test drives is the seam production drives.\n *\n * What this replaces: `runMain(mode)`, which read stdin itself and reached `process.stdout.write` /\n * `process.exit` from a dozen frames down. `runMain` is DELETED, not kept alongside — two spellings of\n * one entry point is the shim shape this repo rejects outright (see CLAUDE.md, \"NO webpieces surface\n * is released backwards-compatible\"). Nothing outside this file names it any more.\n *\n * The order of observable effects is unchanged from `runMain`: the invocation's audit line is flushed\n * at the emit boundary inside the pipeline, the decision bytes are written next, and the process exits\n * last through the injected exit port.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class HookApp {\n private readonly stdin: HookStdinSource;\n private readonly stdout: HookStdoutSink;\n private readonly processExit: HookProcessExit;\n\n constructor(stdin: HookStdinSource, stdout: HookStdoutSink, processExit: HookProcessExit) {\n this.stdin = stdin;\n this.stdout = stdout;\n this.processExit = processExit;\n }\n\n async run(args: HookArgs): Promise<void> {\n const outcome = await this.decide(args);\n // An ALLOW writes NOTHING — a silent exit 0 is the allow in the PreToolUse protocol, and an\n // empty write would still be a write on a pipe somebody is parsing. Guarded here rather than\n // in the sink so the sink stays a dumb port.\n if (outcome.stdout !== '') this.stdout.write(outcome.stdout);\n this.processExit.exit(outcome.exitCode);\n }\n\n /**\n * THE FAIL-CLOSED BOUNDARY FOR THE READ ITSELF, and the reason this is a separate method.\n *\n * `runMain` had the stdin read INSIDE the try whose catch produced a deny, so a failure there was\n * still a structured block. Moving the read behind a port would have quietly narrowed that: a\n * rejected read (or anything else thrown before the pipeline starts) would escape `run`, land as an\n * unhandled rejection, and exit non-zero — which PreToolUse reads as a NON-BLOCKING error and lets\n * the tool call THROUGH. That is the exact inversion of \"a broken hook never silently lets an edit\n * through\", and no golden could catch it, because the goldens substitute this very port.\n *\n * So the try is restored one level out, around the read AND the pipeline, and it emits the same\n * bytes `denyForCrash` emits for a null event.\n */\n private async decide(args: HookArgs): Promise<HookOutcome> {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const raw = await this.stdin.read();\n return runPipeline(raw, args.mode);\n } catch (err: unknown) {\n const error = toError(err);\n return denyOutcome(null, `${CRASH_PREFIX}${error.message}`, 'hook-crash');\n }\n }\n}\n\n/**\n * THE LAST-RESORT FAIL-CLOSED BOUNDARY: the composition root itself could not run.\n *\n * `new Container(...)` and `container.get(HookApp)` happen BEFORE any HookApp exists to catch for\n * them, so an unresolvable binding (a missing decorator, a stripped `design:paramtypes`) would exit\n * non-zero with a stack on stderr — and a non-zero exit is a non-blocking error, so every guarded tool\n * call would sail through unjudged for as long as the defect lasted. Low probability; total\n * consequence. One shared class rather than a copy in each bin, so the two can never drift.\n *\n * It writes through `process` directly, and that is correct rather than a leak: by construction there\n * is no container here to have handed it a port, and this is the same designated terminal boundary the\n * ports themselves wrap.\n */\nexport class HookBootFailure {\n // webpieces-disable no-any-unknown -- a rejection value is `unknown` by construction; toError() below is the one place that narrowing belongs\n report(err: unknown): void {\n const error = toError(err);\n // `null` event ⇒ no `systemMessage`. Deliberate: we never parsed a payload, so we do not know\n // whether this was a Bash call, and inventing the Bash-shaped deny would be a guess.\n // webpieces-disable no-process-exit-outside-main -- the hook's exit code IS the Claude Code PreToolUse protocol (exit 0 + JSON = a block); this is the last-resort terminal boundary, reached only when no container could be built to inject a port.\n process.stdout.write(denyJson(null, `${CRASH_PREFIX}${error.message}`) + '\\n');\n // webpieces-disable no-process-exit-outside-main -- same terminal boundary; exiting 0 is what makes this a BLOCK rather than a non-blocking error that lets the tool call through.\n process.exit(0);\n }\n}\n"]}
|
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
import { HookMode } from '../core/types';
|
|
2
|
+
import { HookOutcome } from './hook-outcome';
|
|
2
3
|
export type { HookMode };
|
|
3
4
|
export type ShimStaleDecision = 'allow-cure' | 'pass' | 'deny';
|
|
4
5
|
export declare function shimStaleRecoveryDecision(toolName: string, command: string, filePath: string): ShimStaleDecision;
|
|
5
6
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* THE PIPELINE, from raw stdin bytes to the decision — the whole of `parse -> adapter -> runner ->
|
|
8
|
+
* emit`, as ONE function returning ONE value.
|
|
9
|
+
*
|
|
10
|
+
* `mode` selects which tool kinds to validate; payloads outside the mode's scope pass through
|
|
11
|
+
* (emitAllow). A block is a PreToolUse `permissionDecision:"deny"` JSON on stdout with exit 0 — see
|
|
12
|
+
* agent-response.ts. Fails CLOSED on any unexpected crash (returns a deny) so a broken hook never
|
|
13
|
+
* silently lets an edit through, and the reason surfaces in the agent's UI instead of being hidden on
|
|
14
|
+
* a stderr+exit-2 block.
|
|
15
|
+
*
|
|
16
|
+
* It reads NO stdin, writes NO stdout and calls NO exit: those three couplings are ports owned by
|
|
17
|
+
* HookApp (see hook-ports.ts), which is what makes the composed pipeline drivable from a test. This
|
|
18
|
+
* function is what `runMain` was; it is not a second spelling of it — `runMain` is deleted, and
|
|
19
|
+
* `HookApp.run()` is the only entry point.
|
|
11
20
|
*/
|
|
12
|
-
export declare function
|
|
21
|
+
export declare function runPipeline(raw: string, mode: HookMode): HookOutcome;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.shimStaleRecoveryDecision = shimStaleRecoveryDecision;
|
|
4
|
-
exports.
|
|
4
|
+
exports.runPipeline = runPipeline;
|
|
5
5
|
const tslib_1 = require("tslib");
|
|
6
6
|
const path = tslib_1.__importStar(require("path"));
|
|
7
7
|
const runner_1 = require("../core/runner");
|
|
@@ -13,69 +13,19 @@ 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 hook_outcome_1 = require("./hook-outcome");
|
|
18
|
+
const agent_payload_1 = require("./agent-payload");
|
|
19
|
+
const agent_adapters_1 = require("./agent-adapters");
|
|
20
|
+
const codex_subagent_guard_1 = require("./codex-subagent-guard");
|
|
17
21
|
const shim_1 = require("../bin/shim");
|
|
18
|
-
const shim_deny_reason_1 = require("../bin/shim-deny-reason");
|
|
19
22
|
const hook_registration_1 = require("../bin/hook-registration");
|
|
23
|
+
const shim_deny_reason_1 = require("../bin/shim-deny-reason");
|
|
20
24
|
const l0_matrix_1 = require("../core/l0-matrix");
|
|
21
25
|
const log_stream_1 = require("../core/log-stream");
|
|
22
26
|
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']);
|
|
30
|
-
function readStdin() {
|
|
31
|
-
return new Promise((resolve) => {
|
|
32
|
-
let data = '';
|
|
33
|
-
process.stdin.setEncoding('utf8');
|
|
34
|
-
process.stdin.on('data', (chunk) => { data += chunk; });
|
|
35
|
-
process.stdin.on('end', () => resolve(data));
|
|
36
|
-
process.stdin.on('error', () => resolve(''));
|
|
37
|
-
if (process.stdin.isTTY)
|
|
38
|
-
resolve('');
|
|
39
|
-
});
|
|
40
|
-
}
|
|
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
|
-
}
|
|
27
|
+
const ADAPTERS = new agent_adapters_1.AgentAdapters();
|
|
28
|
+
const SUBAGENT_GUARD = new codex_subagent_guard_1.CodexSubagentSharedTreeGuard();
|
|
79
29
|
// The rule name for a block's audit line: the FIRST rule the report cites, or `fallback` when the
|
|
80
30
|
// report opens with no `[rule]` header (a hand-written guard message). Comma-joined when a report
|
|
81
31
|
// cites several, so `rule=` never silently drops one.
|
|
@@ -84,14 +34,23 @@ function blockingRule(report, fallback) {
|
|
|
84
34
|
const names = (0, rejection_log_1.extractRuleNames)(report);
|
|
85
35
|
return names.length > 0 ? names.join(',') : fallback;
|
|
86
36
|
}
|
|
87
|
-
function
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
37
|
+
// webpieces-disable no-function-outside-class -- sibling of handleRead()/handleFileTool() in this module; the adapter is module-scope functions by design
|
|
38
|
+
function handleBash(event, cwd, mode) {
|
|
39
|
+
const command = event.bash === null ? '' : event.bash.command;
|
|
40
|
+
if (command.trim() === '') {
|
|
41
|
+
(0, agent_response_1.emitAllow)();
|
|
42
|
+
}
|
|
43
|
+
// READ PARITY, and it can only ever be reached from a Codex event: the adapter leaves `reads`
|
|
44
|
+
// empty for Claude Code, which has a real `Read` tool and its own fast path. A Codex read arrives
|
|
45
|
+
// as `Bash` running a pager, so without this the read guard and the `calls/` audit trail see none
|
|
46
|
+
// of them. The command is STILL run through the bash guards below — this adds a verdict, it never
|
|
47
|
+
// replaces one.
|
|
48
|
+
for (const readPath of event.reads) {
|
|
49
|
+
handleRead(event, readPath, cwd, mode);
|
|
91
50
|
}
|
|
92
51
|
const result = (0, runner_1.runBash)(command, cwd, mode);
|
|
93
52
|
if (!result) {
|
|
94
|
-
(0,
|
|
53
|
+
(0, agent_response_1.emitAllow)();
|
|
95
54
|
}
|
|
96
55
|
// NO DECISION LINE HERE. This used to write a generic `bash-guard` line because a Bash deny once
|
|
97
56
|
// had no audit trail at all — but every layer now records its own: L1 into `L1-location/` with its
|
|
@@ -101,9 +60,10 @@ function handleBash(payload, cwd, mode) {
|
|
|
101
60
|
// the guard actually judged, so a `cd`-relocated command scattered one block across two different
|
|
102
61
|
// `.webpieces` directories.
|
|
103
62
|
//
|
|
104
|
-
// Bash deny →
|
|
105
|
-
// shows the human; permissionDecisionReason is invisible on Bash). See
|
|
106
|
-
|
|
63
|
+
// Bash deny → the event's kind is 'Bash', so denyJson adds the ANSI-red systemMessage (the only
|
|
64
|
+
// field a Bash deny shows the human; permissionDecisionReason is invisible on Bash). See
|
|
65
|
+
// agent-response.ts.
|
|
66
|
+
(0, agent_response_1.emitDeny)(event, result.report, blockingRule(result.report, 'bash-guard'), result.fault);
|
|
107
67
|
}
|
|
108
68
|
/**
|
|
109
69
|
* The read-scoped guard pass. Returns normally to ALLOW; only calls emitDeny when the guard fires.
|
|
@@ -114,7 +74,7 @@ function handleBash(payload, cwd, mode) {
|
|
|
114
74
|
* path deliberately inverts the policy: a broken read-guard degrades to a no-op, never to a wedge.
|
|
115
75
|
*/
|
|
116
76
|
// 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) {
|
|
77
|
+
function handleRead(event, filePath, cwd, mode) {
|
|
118
78
|
if (filePath === '')
|
|
119
79
|
return;
|
|
120
80
|
let result = null;
|
|
@@ -130,19 +90,58 @@ function handleRead(filePath, cwd, mode) {
|
|
|
130
90
|
if (!result)
|
|
131
91
|
return;
|
|
132
92
|
(0, rejection_log_1.logRejection)('Read', new types_1.NormalizedToolInput(filePath, []), result, cwd);
|
|
133
|
-
(0,
|
|
93
|
+
(0, agent_response_1.emitDeny)(event, result.report, blockingRule(result.report, 'read-guard'), result.fault);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Read-only tools (Read): audit-log, warm the main-sync cache, then run the ONE read-scoped guard
|
|
97
|
+
* (read-stale-guard) and allow. Runs BEFORE the general rule engine — no code-style rule ever sees a
|
|
98
|
+
* Read, and the only way this path can deny is a stale `main`. The audit trail still records every
|
|
99
|
+
* file the AI opened (see setup.ts).
|
|
100
|
+
*
|
|
101
|
+
* Returns normally ONLY when the event is not a Read; otherwise it ends the invocation.
|
|
102
|
+
*/
|
|
103
|
+
// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design
|
|
104
|
+
function handleReadFastPath(event, cwd, mode) {
|
|
105
|
+
if (event.kind !== 'Read')
|
|
106
|
+
return;
|
|
107
|
+
const readPath = event.reads.length > 0 ? event.reads[0] : '';
|
|
108
|
+
if (mode !== 'rules') {
|
|
109
|
+
decision_log_1.invocationLog.begin(cwd, event.rawToolName, readPath);
|
|
110
|
+
// Reads vastly outnumber edits, so refreshing here is what actually keeps the shared
|
|
111
|
+
// main-sync cache warm for feature-branch-guard. Detached; never slows the read.
|
|
112
|
+
(0, main_sync_refresh_1.triggerMainSyncRefresh)(cwd, (0, main_sync_timeout_1.branchStateHangTimeoutFor)(cwd));
|
|
113
|
+
}
|
|
114
|
+
handleRead(event, readPath, cwd, mode);
|
|
115
|
+
(0, agent_response_1.emitAllow)();
|
|
134
116
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
117
|
+
/**
|
|
118
|
+
* The file/edit pipeline, run once per file the call touches.
|
|
119
|
+
*
|
|
120
|
+
* `event.files` is a LIST because ONE Codex `apply_patch` carries many files with mixed operations.
|
|
121
|
+
* A Claude Code event always has exactly one entry, so the loop runs once and the behaviour is the
|
|
122
|
+
* single-file behaviour it has always had.
|
|
123
|
+
*/
|
|
124
|
+
// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleRead() in this module; the adapter is module-scope functions by design
|
|
125
|
+
function handleFileTool(event, cwd, mode) {
|
|
126
|
+
if (event.files.length === 0) {
|
|
127
|
+
(0, agent_response_1.emitAllow)();
|
|
128
|
+
}
|
|
129
|
+
// A Codex SUBAGENT writing into the tree it shares with its coordinator. Returns null for every
|
|
130
|
+
// Claude Code event — that harness can hand a subagent its own worktree, and does.
|
|
131
|
+
const subagentBlock = SUBAGENT_GUARD.check(event, new rules_config_1.RepoRootFinder().resolveRepoRoot(cwd));
|
|
132
|
+
if (subagentBlock) {
|
|
133
|
+
(0, agent_response_1.emitDeny)(event, subagentBlock.report, codex_subagent_guard_1.CODEX_SUBAGENT_RULE, subagentBlock.fault);
|
|
139
134
|
}
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
(0, claude_code_response_1.emitAllow)();
|
|
135
|
+
for (const file of event.files) {
|
|
136
|
+
handleOneFile(event, file, cwd, mode);
|
|
143
137
|
}
|
|
138
|
+
(0, agent_response_1.emitAllow)();
|
|
139
|
+
}
|
|
140
|
+
// webpieces-disable no-function-outside-class -- sibling of handleBash()/handleFileTool() in this module; the adapter is module-scope functions by design
|
|
141
|
+
function handleOneFile(event, file, cwd, mode) {
|
|
142
|
+
const input = file.input;
|
|
144
143
|
// Always allow edits to webpieces.config.json — it's the fix target when the config is broken.
|
|
145
|
-
// This
|
|
144
|
+
// This returns BEFORE run(), so feature-branch-guard never sees a config edit; record that so the
|
|
146
145
|
// audit trail explains why a config edit on a bad branch was not blocked (see decision-log.ts).
|
|
147
146
|
if (path.basename(input.filePath) === load_config_1.CONFIG_FILENAME) {
|
|
148
147
|
if (mode !== 'rules') {
|
|
@@ -150,22 +149,21 @@ function handleFileTool(payload, cwd, mode) {
|
|
|
150
149
|
// root, not the AI's cwd — resolve it so a config edit from a subdir doesn't create a
|
|
151
150
|
// stray `<subdir>/.webpieces` tree.
|
|
152
151
|
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)')));
|
|
152
|
+
(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
153
|
// The guard's own refresh trigger lives inside its check(), which we skip here — so warm
|
|
155
154
|
// the cache directly, otherwise a session that only edits webpieces.config.json never
|
|
156
155
|
// refreshes the sync status. Fire-and-forget; never blocks the edit.
|
|
157
156
|
(0, main_sync_refresh_1.triggerMainSyncRefresh)(root, (0, main_sync_timeout_1.branchStateHangTimeoutFor)(cwd));
|
|
158
157
|
}
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
const result = (0, runner_1.run)(toolKind, input, cwd, mode);
|
|
162
|
-
if (!result) {
|
|
163
|
-
(0, claude_code_response_1.emitAllow)();
|
|
158
|
+
return;
|
|
164
159
|
}
|
|
165
|
-
(0,
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
(0,
|
|
160
|
+
const result = (0, runner_1.run)(file.toolKind, input, cwd, mode);
|
|
161
|
+
if (!result)
|
|
162
|
+
return;
|
|
163
|
+
(0, rejection_log_1.logRejection)(file.toolKind, input, result, cwd);
|
|
164
|
+
// File-tool deny → the event's kind is 'File', so denyJson omits systemMessage (the reason
|
|
165
|
+
// already renders red natively for these tools). See agent-response.ts.
|
|
166
|
+
(0, agent_response_1.emitDeny)(event, result.report, blockingRule(result.report, 'file-guard'), result.fault);
|
|
169
167
|
}
|
|
170
168
|
// webpieces-disable no-function-outside-class -- pure decision helper beside the adapter's other module-scope functions; exported for direct unit testing.
|
|
171
169
|
function shimStaleRecoveryDecision(toolName, command, filePath) {
|
|
@@ -187,8 +185,13 @@ function shimStaleRecoveryDecision(toolName, command, filePath) {
|
|
|
187
185
|
// shimStaleRecoveryDecision): the whole L0 allowlist, any Read, and editing webpieces.config.json. We deny +
|
|
188
186
|
// tell the AI; we do NOT silently rewrite the file under it. 'rules' hook skips it (guards owns the
|
|
189
187
|
// shim). Returns normally (pass / nothing to do) or exits via emitAllow/emitDeny.
|
|
188
|
+
//
|
|
189
|
+
// It asks the allowlist about the RAW WIRE FIELDS, not about the normalized event, and that ordering is
|
|
190
|
+
// deliberate: L0 has to hold on a tree too broken to trust anything above it, including the adapters.
|
|
191
|
+
// The raw fields are the same key names in both harnesses (measured), so one reading serves both, and
|
|
192
|
+
// the answer cannot change because a normalizer changed. `event` is here only to decorate the deny.
|
|
190
193
|
// 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) {
|
|
194
|
+
function enforceCommittedShim(payload, event, cwd, mode) {
|
|
192
195
|
// ONE root for the whole decision, resolved from the RUNNING MODULE (governingShimRoot), never from
|
|
193
196
|
// `cwd`: the shim file we compare and the renderShim() we compare it TO must come from the same
|
|
194
197
|
// install, or the check straddles two trees and can never converge (see governingShimRoot's header).
|
|
@@ -210,7 +213,7 @@ function enforceCommittedShim(payload, cwd, mode) {
|
|
|
210
213
|
if (decision === 'pass')
|
|
211
214
|
return;
|
|
212
215
|
if (decision === 'allow-cure')
|
|
213
|
-
(0,
|
|
216
|
+
(0, agent_response_1.emitAllow)();
|
|
214
217
|
// Drop the L0 matrix doc where the AI can read it and point the deny at it — a Read is entry 1 of
|
|
215
218
|
// the same allowlist, so the pointer is always followable. Best-effort: no doc → no pointer.
|
|
216
219
|
const root = new rules_config_1.RepoRootFinder().resolveRepoRoot(cwd);
|
|
@@ -224,96 +227,111 @@ function enforceCommittedShim(payload, cwd, mode) {
|
|
|
224
227
|
// L0 fault S in GUARD_MATRIX.md's codebook — named as the blocking rule so the invocation line
|
|
225
228
|
// says WHAT stopped the call, not merely that something did, and stamped as `fault=S` so the same
|
|
226
229
|
// 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
|
|
230
|
+
// A subagent is discriminated by `agent_id`, which BOTH harnesses populate on stdin only off the
|
|
231
|
+
// main loop (main falls back to the session id / leaves it empty). Its cure differs: the hooks
|
|
229
232
|
// blocking it resolve through CLAUDE_PROJECT_DIR, which names the MAIN tree.
|
|
230
|
-
const inSubagent =
|
|
231
|
-
(0,
|
|
233
|
+
const inSubagent = event.agentId !== '';
|
|
234
|
+
(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
235
|
}
|
|
233
236
|
/**
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
237
|
+
* THE PIPELINE, from raw stdin bytes to the decision — the whole of `parse -> adapter -> runner ->
|
|
238
|
+
* emit`, as ONE function returning ONE value.
|
|
239
|
+
*
|
|
240
|
+
* `mode` selects which tool kinds to validate; payloads outside the mode's scope pass through
|
|
241
|
+
* (emitAllow). A block is a PreToolUse `permissionDecision:"deny"` JSON on stdout with exit 0 — see
|
|
242
|
+
* agent-response.ts. Fails CLOSED on any unexpected crash (returns a deny) so a broken hook never
|
|
243
|
+
* silently lets an edit through, and the reason surfaces in the agent's UI instead of being hidden on
|
|
244
|
+
* a stderr+exit-2 block.
|
|
245
|
+
*
|
|
246
|
+
* It reads NO stdin, writes NO stdout and calls NO exit: those three couplings are ports owned by
|
|
247
|
+
* HookApp (see hook-ports.ts), which is what makes the composed pipeline drivable from a test. This
|
|
248
|
+
* function is what `runMain` was; it is not a second spelling of it — `runMain` is deleted, and
|
|
249
|
+
* `HookApp.run()` is the only entry point.
|
|
239
250
|
*/
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
|
|
251
|
+
// 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
|
|
252
|
+
function runPipeline(raw, mode) {
|
|
253
|
+
// Captured as soon as the ENVELOPE parses so the fail-closed catch below can tell denyJson which
|
|
254
|
+
// kind of call it is denying — a crash on a Bash call still gets the visible red systemMessage, a
|
|
255
|
+
// crash on a file tool does not. Null (before parse / malformed input) → treated as non-Bash.
|
|
256
|
+
let event = null;
|
|
245
257
|
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
246
258
|
try {
|
|
247
|
-
const
|
|
248
|
-
const payload = safeParse(raw);
|
|
259
|
+
const payload = new agent_payload_1.AgentPayloadParser().parse(raw);
|
|
249
260
|
if (!payload) {
|
|
250
|
-
(0,
|
|
261
|
+
(0, agent_response_1.emitAllow)();
|
|
251
262
|
}
|
|
252
|
-
|
|
263
|
+
// The envelope shape first: it reads only `tool_name` and the identity fields, so it cannot
|
|
264
|
+
// fail, and it is what the crash path needs. The full normalization below reads `tool_input`
|
|
265
|
+
// and CAN fail (a malformed Codex patch envelope denies rather than being half-understood).
|
|
266
|
+
event = ADAPTERS.envelope(payload);
|
|
253
267
|
// BEFORE enforceCommittedShim(), which can itself write a BLOCK line. See LogStream for why
|
|
254
268
|
// 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(
|
|
269
|
+
log_stream_1.logStream.identify(new log_stream_1.StreamIdentity(event.sessionId, event.agentId, mode));
|
|
256
270
|
// Prefer the payload cwd (the AI's actual working dir, follows a persisted `cd`) over
|
|
257
271
|
// process.cwd(); they match today, but the payload is the authoritative signal and stays
|
|
258
272
|
// correct if the hook is ever invoked from a fixed dir (e.g. via $CLAUDE_PROJECT_DIR).
|
|
259
273
|
const cwd = payload.cwd ?? process.cwd();
|
|
260
274
|
// Committed-shim self-guard: blocks real work while the committed shim is stale, but keeps the
|
|
261
275
|
// recovery path open (cures, reads, config edit). See enforceCommittedShim / shimStaleRecoveryDecision.
|
|
262
|
-
enforceCommittedShim(payload, cwd, mode);
|
|
263
|
-
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
// The audit trail still records every file the AI opened (see setup.ts).
|
|
267
|
-
if (READ_ONLY_TOOLS.has(payload.tool_name)) {
|
|
268
|
-
const readPath = payload.tool_input.file_path ?? '';
|
|
269
|
-
if (mode !== 'rules') {
|
|
270
|
-
decision_log_1.invocationLog.begin(cwd, payload.tool_name, readPath);
|
|
271
|
-
// Reads vastly outnumber edits, so refreshing here is what actually keeps the shared
|
|
272
|
-
// main-sync cache warm for feature-branch-guard. Detached; never slows the read.
|
|
273
|
-
(0, main_sync_refresh_1.triggerMainSyncRefresh)(cwd, (0, main_sync_timeout_1.branchStateHangTimeoutFor)(cwd));
|
|
274
|
-
}
|
|
275
|
-
handleRead(readPath, cwd, mode);
|
|
276
|
-
(0, claude_code_response_1.emitAllow)();
|
|
277
|
-
}
|
|
276
|
+
enforceCommittedShim(payload, event, cwd, mode);
|
|
277
|
+
event = ADAPTERS.toEvent(payload, cwd);
|
|
278
|
+
// Read-only tools: their own fast path, which never returns when it applies.
|
|
279
|
+
handleReadFastPath(event, cwd, mode);
|
|
278
280
|
// Per-invocation guard log (the `calls/` stream): tool + command/file + live branch +
|
|
279
281
|
// main-sync-status snapshot, on EVERY guards call, for later cleanup automation. Best-effort;
|
|
280
282
|
// never blocks the call. (The committed shim is no longer silently healed here — a mismatch is
|
|
281
283
|
// reported by the self-guard above, not rewritten out from under the AI.)
|
|
282
284
|
if (mode !== 'rules') {
|
|
283
|
-
|
|
284
|
-
decision_log_1.invocationLog.begin(cwd, payload.tool_name, target);
|
|
285
|
+
decision_log_1.invocationLog.begin(cwd, event.rawToolName, logTarget(event));
|
|
285
286
|
}
|
|
286
|
-
if (
|
|
287
|
+
if (event.kind === 'Bash') {
|
|
287
288
|
// No code-style rule is bash-scoped, so the rules hook ignores Bash.
|
|
288
289
|
if (mode === 'rules') {
|
|
289
|
-
(0,
|
|
290
|
+
(0, agent_response_1.emitAllow)();
|
|
290
291
|
}
|
|
291
|
-
handleBash(
|
|
292
|
-
return;
|
|
292
|
+
handleBash(event, cwd, mode);
|
|
293
293
|
}
|
|
294
294
|
// File payloads run in 'rules' (code-style), 'guards' (file-scoped guards like
|
|
295
295
|
// feature-branch-guard), and 'all'. The runner filters to the right category.
|
|
296
|
-
handleFileTool(
|
|
296
|
+
handleFileTool(event, cwd, mode);
|
|
297
297
|
}
|
|
298
298
|
catch (err) {
|
|
299
299
|
const error = (0, to_error_1.toError)(err);
|
|
300
|
-
|
|
300
|
+
// The pipeline's own terminal control flow, not a failure: emitAllow/emitDeny threw the answer
|
|
301
|
+
// out to here from wherever they were called. Treating it as a crash would turn every allow
|
|
302
|
+
// into a deny, so this branch comes FIRST and returns the carried outcome verbatim.
|
|
303
|
+
if (error instanceof hook_outcome_1.HookTerminated)
|
|
304
|
+
return error.outcome;
|
|
305
|
+
return denyForCrash(error, event);
|
|
301
306
|
}
|
|
302
307
|
}
|
|
308
|
+
// What the `calls/` audit line names as the call's target: the command for a shell call, else the first
|
|
309
|
+
// file it touches. A Codex `apply_patch` touching several files names the first — the rejection log and
|
|
310
|
+
// the decision log carry the rest, per file.
|
|
311
|
+
// webpieces-disable no-function-outside-class -- sibling of the module-scope hook entry points in this adapter
|
|
312
|
+
function logTarget(event) {
|
|
313
|
+
if (event.kind === 'Bash')
|
|
314
|
+
return event.bash === null ? '' : event.bash.command;
|
|
315
|
+
return event.files.length > 0 ? event.files[0].input.filePath : '';
|
|
316
|
+
}
|
|
303
317
|
/**
|
|
304
318
|
* 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
|
-
*
|
|
319
|
+
* that threw past the runner's per-rule catch) or an InformAiError (bad config/stdin, or a Codex patch
|
|
320
|
+
* envelope this parser refuses to guess at) both carry an AI-readable message; anything else is an
|
|
321
|
+
* unexpected bug. All three DENY and surface their reason, because a hook that crashed established
|
|
322
|
+
* nothing and must never be read as an allow.
|
|
323
|
+
*
|
|
324
|
+
* It BUILDS the deny (denyOutcome) rather than throwing it (emitDeny), because it is already inside
|
|
325
|
+
* the catch that the throw would land in — see HookTerminated. Same bytes either way.
|
|
308
326
|
*/
|
|
309
327
|
// 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,
|
|
328
|
+
function denyForCrash(error, event) {
|
|
311
329
|
if (error instanceof types_1.RuleFailError) {
|
|
312
|
-
(0,
|
|
330
|
+
return (0, agent_response_1.denyOutcome)(event, (0, rules_config_1.renderRuleFailForAi)(error), 'rule-crash');
|
|
313
331
|
}
|
|
314
332
|
if (error instanceof types_1.InformAiError) {
|
|
315
|
-
(0,
|
|
333
|
+
return (0, agent_response_1.denyOutcome)(event, error.message, 'bad-config-or-stdin');
|
|
316
334
|
}
|
|
317
|
-
(0,
|
|
335
|
+
return (0, agent_response_1.denyOutcome)(event, `[ai-hooks] hook crashed unexpectedly — failing closed: ${error.message}`, 'hook-crash');
|
|
318
336
|
}
|
|
319
337
|
//# sourceMappingURL=hook-core.js.map
|