@ngockhoale/ukit 2.7.4 → 2.7.6
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/CHANGELOG.md +64 -0
- package/manifests/platform.full.yaml +43 -0
- package/package.json +1 -1
- package/src/core/ompConfigMerge.js +7 -2
- package/src/core/permissionDoctor.js +59 -4
- package/src/core/permissionPolicy.js +1 -1
- package/templates/.claude/hooks/block-dangerous.mjs +377 -0
- package/templates/.claude/hooks/block-dangerous.sh +24 -223
- package/templates/.claude/hooks/record-execution.mjs +141 -0
- package/templates/.claude/hooks/record-execution.sh +14 -9
- package/templates/.claude/hooks/sensitive-data-guard.mjs +513 -0
- package/templates/.claude/hooks/sensitive-data-guard.sh +1 -353
- package/templates/.claude/settings.json +6 -7
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +17 -1
- package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +134 -26
- package/templates/.claude/ukit/runtime/hook-field-salvage.mjs +119 -0
- package/templates/.omp/config.yml +9 -8
- package/templates/.omp/hooks/pre/ukit-bridge.js +20 -12
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
5
6
|
// TASK-016: children run through the process-tree runner instead of bare
|
|
6
7
|
// spawnSync — a timed-out child is TERM→KILLed as a whole POSIX process group,
|
|
7
8
|
// so a bash wrapper can no longer orphan the Node grandchildren it spawned.
|
|
8
9
|
import { runHookProcess } from './hook-process.mjs';
|
|
9
10
|
// TASK-019: chain rows and direct-hook rows share one versioned schema and one
|
|
10
11
|
// append API (hook-telemetry.mjs) — including safeName for session files.
|
|
11
|
-
import { appendTelemetryRow, TELEMETRY_VERSION } from './hook-telemetry.mjs';
|
|
12
|
+
import { appendTelemetryRow, recordHookTiming, TELEMETRY_VERSION } from './hook-telemetry.mjs';
|
|
12
13
|
// TASK-018 review fix round 1: the chain budget is resolved by ONE shared module
|
|
13
14
|
// so the runner's inner deadline and the bridge's outer pi.exec timeout can never
|
|
14
15
|
// disagree. Kept as a per-run resolution (not module constants) so an operator's
|
|
@@ -26,6 +27,12 @@ const FAIL_CLOSED_SCRIPTS = new Set([
|
|
|
26
27
|
'handoff-model-guard.sh',
|
|
27
28
|
'context-hardcap-gate.sh',
|
|
28
29
|
'block-dangerous.sh',
|
|
30
|
+
// TASK-001 (SPEC §FR-002): in-proc module steps join the fail-closed registry.
|
|
31
|
+
// The runner's static set is authoritative for .mjs steps — an import that
|
|
32
|
+
// never finishes cannot declare `hookFailClosed`. Basenames only; the `:N`
|
|
33
|
+
// suffix is stripped before lookup by the same parser used for .sh args.
|
|
34
|
+
'sensitive-data-guard.mjs',
|
|
35
|
+
'block-dangerous.mjs',
|
|
29
36
|
]);
|
|
30
37
|
|
|
31
38
|
const MAX_BUFFER_BYTES = 2 * 1024 * 1024;
|
|
@@ -110,6 +117,67 @@ function parseScriptArg(arg) {
|
|
|
110
117
|
return { scriptPath: match[1], timeoutMs: Math.round(seconds * 1000) };
|
|
111
118
|
}
|
|
112
119
|
|
|
120
|
+
// TASK-001 (SPEC §FR-001, §8): a chain arg ending in `.mjs` is an IN-PROC step —
|
|
121
|
+
// `await import()` + `mod.runHook(ctx)` instead of a spawned child. The result
|
|
122
|
+
// is shaped like a hook-process result so `chainFailureKind` and the results[]
|
|
123
|
+
// entry stay identical: throw → failureKind 'error'; deadline → 'deadline' →
|
|
124
|
+
// 'timeout'. The deadline is enforced by Promise.race + AbortSignal; the
|
|
125
|
+
// rejection race keeps running in the background (in-proc code cannot be
|
|
126
|
+
// SIGKILLed) but its late return value is discarded.
|
|
127
|
+
async function runModuleStep({ scriptPath, payload, payloadText, projectRoot, env, deadlineMs }) {
|
|
128
|
+
const controller = new AbortController();
|
|
129
|
+
let timer;
|
|
130
|
+
const timeout = new Promise((resolve) => {
|
|
131
|
+
timer = setTimeout(() => {
|
|
132
|
+
controller.abort();
|
|
133
|
+
resolve({ failureKind: 'deadline' });
|
|
134
|
+
}, Math.max(1, deadlineMs));
|
|
135
|
+
});
|
|
136
|
+
const invoke = (async () => {
|
|
137
|
+
// FR-001: module-level self-deadlines that call process.exit must never arm
|
|
138
|
+
// inside the runner — scrub the env var before the import executes module
|
|
139
|
+
// top-level code AND from the ctx.env the step inspects. The deadline is
|
|
140
|
+
// passed explicitly via ctx.deadlineMs/ctx.signal.
|
|
141
|
+
const scrubbed = 'UKIT_HOOK_DEADLINE_MS' in process.env
|
|
142
|
+
? process.env.UKIT_HOOK_DEADLINE_MS : undefined;
|
|
143
|
+
delete process.env.UKIT_HOOK_DEADLINE_MS;
|
|
144
|
+
try {
|
|
145
|
+
const mod = await import(pathToFileURL(scriptPath).href);
|
|
146
|
+
const childEnv = { ...env };
|
|
147
|
+
delete childEnv.UKIT_HOOK_DEADLINE_MS;
|
|
148
|
+
const out = await mod.runHook({
|
|
149
|
+
payload,
|
|
150
|
+
payloadText,
|
|
151
|
+
projectRoot,
|
|
152
|
+
env: childEnv,
|
|
153
|
+
deadlineMs,
|
|
154
|
+
signal: controller.signal,
|
|
155
|
+
});
|
|
156
|
+
return {
|
|
157
|
+
code: Number.isFinite(out?.code) ? out.code : 1,
|
|
158
|
+
stdout: typeof out?.stdout === 'string' ? out.stdout : '',
|
|
159
|
+
stderr: typeof out?.stderr === 'string' ? out.stderr : '',
|
|
160
|
+
};
|
|
161
|
+
} finally {
|
|
162
|
+
// Skip the restore once this step was aborted: the race already returned,
|
|
163
|
+
// so this finally can run while the NEXT step is mid-import — restoring
|
|
164
|
+
// the var then would arm that module's top-level self-deadline inside the
|
|
165
|
+
// runner. Every step re-scrubs before its own import, so leaking the
|
|
166
|
+
// deletion is safe.
|
|
167
|
+
if (scrubbed !== undefined && !controller.signal.aborted) {
|
|
168
|
+
process.env.UKIT_HOOK_DEADLINE_MS = scrubbed;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
})();
|
|
172
|
+
try {
|
|
173
|
+
return await Promise.race([invoke, timeout]);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
return { failureKind: 'error', stderr: error?.message || String(error) };
|
|
176
|
+
} finally {
|
|
177
|
+
clearTimeout(timer);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
113
181
|
async function run(payloadText, scriptArgs, { chainMarker = true } = {}) {
|
|
114
182
|
const parsedArgs = scriptArgs.map(parseScriptArg);
|
|
115
183
|
const scriptPaths = parsedArgs.map((a) => a.scriptPath);
|
|
@@ -176,28 +244,47 @@ async function run(payloadText, scriptArgs, { chainMarker = true } = {}) {
|
|
|
176
244
|
}
|
|
177
245
|
|
|
178
246
|
const childStartedAt = Date.now();
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
247
|
+
const stepEnv = {
|
|
248
|
+
...process.env,
|
|
249
|
+
CLAUDE_PROJECT_DIR: projectRoot,
|
|
250
|
+
// TASK-234: the marker selects the omp structured-decision contract
|
|
251
|
+
// (ask + exit 2). Under --emit-verdict the runner replays the direct
|
|
252
|
+
// Claude contract (deny + exit 0), so children must NOT see it.
|
|
253
|
+
...(chainMarker ? { UKIT_HOOK_CHAIN_RUNNER: '1' } : {}),
|
|
254
|
+
};
|
|
255
|
+
const stepDeadlineMs = Math.min(childBudgetsMs[scriptIndex], remainingMs);
|
|
256
|
+
// TASK-001: `.mjs` args run in-proc via runHook(ctx); `.sh` stays a child
|
|
257
|
+
// process. Both paths share deadline/budget, results[] shape, and break
|
|
258
|
+
// conditions.
|
|
259
|
+
const result = scriptPath.endsWith('.mjs')
|
|
260
|
+
? await runModuleStep({
|
|
261
|
+
scriptPath,
|
|
262
|
+
payload,
|
|
263
|
+
payloadText,
|
|
264
|
+
projectRoot,
|
|
265
|
+
env: stepEnv,
|
|
266
|
+
deadlineMs: stepDeadlineMs,
|
|
267
|
+
})
|
|
268
|
+
: await runHookProcess({
|
|
269
|
+
command: scriptPath,
|
|
270
|
+
deadlineMs: stepDeadlineMs,
|
|
271
|
+
args: [],
|
|
272
|
+
input: payloadText,
|
|
273
|
+
maxBuffer: MAX_BUFFER_BYTES,
|
|
274
|
+
cwd: projectRoot,
|
|
275
|
+
// TASK-223 (HK-401): mark chain-spawned children so their structured
|
|
276
|
+
// permission decisions keep the omp contract (stdout parsed on
|
|
277
|
+
// exit 2). Direct Claude Code invocations carry no marker and exit 0
|
|
278
|
+
// instead — a non-zero exit there discards stdout, killing the
|
|
279
|
+
// decision JSON.
|
|
280
|
+
env: stepEnv,
|
|
281
|
+
});
|
|
199
282
|
const failureKind = chainFailureKind(result);
|
|
200
|
-
|
|
283
|
+
// FR-001: on infra failure (error/timeout — no module verdict) the result
|
|
284
|
+
// code is 2 when the step is fail-closed, else 1.
|
|
285
|
+
const code = Number.isFinite(result.code)
|
|
286
|
+
? result.code
|
|
287
|
+
: (FAIL_CLOSED_SCRIPTS.has(scriptName) ? 2 : 1);
|
|
201
288
|
// `killed` keeps its old meaning for the bridge: the child was stopped before
|
|
202
289
|
// a natural exit. The DISTINCT cause lives in failureKind — an overflowing
|
|
203
290
|
// child is no longer reported as a generic kill or a timeout.
|
|
@@ -218,6 +305,22 @@ async function run(payloadText, scriptArgs, { chainMarker = true } = {}) {
|
|
|
218
305
|
elapsedMs: Date.now() - childStartedAt,
|
|
219
306
|
});
|
|
220
307
|
|
|
308
|
+
// TASK-001 (FR-003): `.sh` children write their own hook-latency row via
|
|
309
|
+
// hook-telemetry.sh --finish; in-proc `.mjs` steps cannot — the runner
|
|
310
|
+
// emits the equivalent row per module step (hook = basename incl. `.mjs`).
|
|
311
|
+
if (scriptPath.endsWith('.mjs')) {
|
|
312
|
+
recordHookTiming({
|
|
313
|
+
projectRoot,
|
|
314
|
+
sessionId: payload?.session_id,
|
|
315
|
+
event: payload?.hook_event_name || null,
|
|
316
|
+
tool: payload?.tool_name || null,
|
|
317
|
+
toolUseId: payload?.tool_use_id || null,
|
|
318
|
+
hook: scriptName,
|
|
319
|
+
elapsedMs: Date.now() - childStartedAt,
|
|
320
|
+
outcome: failureKind,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
221
324
|
if (code === 2 || killed || (code !== 0 && FAIL_CLOSED_SCRIPTS.has(scriptName))) {
|
|
222
325
|
break;
|
|
223
326
|
}
|
|
@@ -371,15 +474,20 @@ try {
|
|
|
371
474
|
} else {
|
|
372
475
|
const lastIsFailClosed = FAIL_CLOSED_SCRIPTS.has(last.scriptName);
|
|
373
476
|
const lastFailedToVerdict = last.killed || last.failureKind === 'error' || last.failureKind === 'budget-exhausted';
|
|
374
|
-
|
|
477
|
+
// Check failed-to-verdict BEFORE `last.code === 2`: infra failures on a
|
|
478
|
+
// fail-closed step now synthesize code 2, so a killed gate would
|
|
479
|
+
// otherwise take the plain code-2 branch and lose the "(<failureKind>)"
|
|
480
|
+
// diagnostic. A REAL code 2 has no killed/error/budget failureKind, so
|
|
481
|
+
// the reorder can't misroute a legitimate block.
|
|
482
|
+
if (lastIsFailClosed && lastFailedToVerdict) {
|
|
375
483
|
if (contextStdout) process.stdout.write(contextStdout);
|
|
376
|
-
if (last.stdout) process.stdout.write(last.stdout);
|
|
377
484
|
if (last.stderr) process.stderr.write(last.stderr);
|
|
485
|
+
else process.stderr.write(`UKit fail-closed hook ${last.scriptName} could not produce a verdict (${last.failureKind})\n`);
|
|
378
486
|
process.exitCode = 2;
|
|
379
|
-
} else if (
|
|
487
|
+
} else if (last.code === 2) {
|
|
380
488
|
if (contextStdout) process.stdout.write(contextStdout);
|
|
489
|
+
if (last.stdout) process.stdout.write(last.stdout);
|
|
381
490
|
if (last.stderr) process.stderr.write(last.stderr);
|
|
382
|
-
else process.stderr.write(`UKit fail-closed hook ${last.scriptName} could not produce a verdict (${last.failureKind})\n`);
|
|
383
491
|
process.exitCode = 2;
|
|
384
492
|
} else {
|
|
385
493
|
if (contextStdout) process.stdout.write(contextStdout);
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// hook-field-salvage.mjs — recover a JSON string field from truncated payload
|
|
2
|
+
// text. Shared helper (SPEC C40 §FR-007): ports ukit_salvage_tool_field from
|
|
3
|
+
// hook-input.sh so in-proc module steps (sensitive-data-guard.mjs now,
|
|
4
|
+
// block-dangerous.mjs in TASK-003) can salvage a decision-relevant field without
|
|
5
|
+
// spawning the bash salvage path.
|
|
6
|
+
//
|
|
7
|
+
// Contract:
|
|
8
|
+
// salvageField(text, dotPath) -> { complete: boolean, value: string|null }
|
|
9
|
+
// complete:true — the field's string value was fully present: its closing
|
|
10
|
+
// quote AND a following `,`/`}` boundary appear before the
|
|
11
|
+
// cut; `value` is the JSON-decoded string.
|
|
12
|
+
// complete:false — unrecoverable: missing key, non-string leaf, cut inside
|
|
13
|
+
// the string, EOF right after the quote, malformed prefix.
|
|
14
|
+
// `value` is null. Callers map this to fail-closed — a
|
|
15
|
+
// partial field is never scanned (a truncated dangerous
|
|
16
|
+
// shape could parse benign = fail-open).
|
|
17
|
+
// Single string fields only — this is a salvage step, not a JSON repairer.
|
|
18
|
+
|
|
19
|
+
const isWs = (c) => c === ' ' || c === '\t' || c === '\n' || c === '\r';
|
|
20
|
+
const skipWs = (s, i) => {
|
|
21
|
+
while (i < s.length && isWs(s[i])) i += 1;
|
|
22
|
+
return i;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// End index of the string literal starting at `start` (which must be `"`), or -1
|
|
26
|
+
// when the string is cut before its closing quote.
|
|
27
|
+
const scanStringEnd = (s, start) => {
|
|
28
|
+
for (let i = start + 1; i < s.length; i += 1) {
|
|
29
|
+
const c = s[i];
|
|
30
|
+
if (c === '\\') {
|
|
31
|
+
i += 1;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (c === '"') return i;
|
|
35
|
+
}
|
|
36
|
+
return -1;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Closing brace matching the `{` at `open` (string-aware), or -1 if unclosed.
|
|
40
|
+
const matchBrace = (s, open) => {
|
|
41
|
+
let depth = 0;
|
|
42
|
+
for (let i = open; i < s.length; i += 1) {
|
|
43
|
+
const c = s[i];
|
|
44
|
+
if (c === '"') {
|
|
45
|
+
const end = scanStringEnd(s, i);
|
|
46
|
+
if (end === -1) return -1;
|
|
47
|
+
i = end;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (c === '{') depth += 1;
|
|
51
|
+
else if (c === '}') {
|
|
52
|
+
depth -= 1;
|
|
53
|
+
if (depth === 0) return i;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return -1;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Find `"key"` used as an object key inside region [lo, hi); returns the index
|
|
60
|
+
// of its `:` or -1. A bare `"key"` inside a string value cannot produce this
|
|
61
|
+
// shape (its quotes are escaped), and non-key uses lack the `:` — both are
|
|
62
|
+
// skipped by scanning forward.
|
|
63
|
+
const findKey = (s, key, lo, hi) => {
|
|
64
|
+
const needle = `"${key}"`;
|
|
65
|
+
let pos = s.indexOf(needle, lo);
|
|
66
|
+
while (pos !== -1 && pos < hi) {
|
|
67
|
+
const colon = skipWs(s, pos + needle.length);
|
|
68
|
+
if (colon < hi && colon < s.length && s[colon] === ':') {
|
|
69
|
+
const prev = pos - 1;
|
|
70
|
+
const pc = prev >= 0 ? s[prev] : '';
|
|
71
|
+
if (prev < 0 || pc === '{' || pc === ',' || isWs(pc)) return colon;
|
|
72
|
+
}
|
|
73
|
+
pos = s.indexOf(needle, pos + 1);
|
|
74
|
+
}
|
|
75
|
+
return -1;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const INCOMPLETE = { complete: false, value: null };
|
|
79
|
+
|
|
80
|
+
export function salvageField(text, dotPath) {
|
|
81
|
+
const data = typeof text === 'string' ? text : '';
|
|
82
|
+
const dotted = String(dotPath || '')
|
|
83
|
+
.split('.')
|
|
84
|
+
.filter(Boolean);
|
|
85
|
+
if (!data || data[0] !== '{' || dotted.length === 0 || dotted.length > 4) return INCOMPLETE;
|
|
86
|
+
|
|
87
|
+
let regionLo = 0;
|
|
88
|
+
let regionHi = data.length;
|
|
89
|
+
for (let k = 0; k < dotted.length; k += 1) {
|
|
90
|
+
const colon = findKey(data, dotted[k], regionLo, regionHi);
|
|
91
|
+
if (colon === -1) return INCOMPLETE;
|
|
92
|
+
const vstart = skipWs(data, colon + 1);
|
|
93
|
+
if (vstart >= data.length) return INCOMPLETE;
|
|
94
|
+
const last = k === dotted.length - 1;
|
|
95
|
+
const c = data[vstart];
|
|
96
|
+
if (!last) {
|
|
97
|
+
if (c !== '{') return INCOMPLETE;
|
|
98
|
+
const close = matchBrace(data, vstart);
|
|
99
|
+
// An unclosed parent object still bounds the search to what arrived; the
|
|
100
|
+
// leaf's own boundary proof below decides completeness.
|
|
101
|
+
regionLo = vstart + 1;
|
|
102
|
+
regionHi = close === -1 ? data.length : close;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (c !== '"') return INCOMPLETE; // string fields only
|
|
106
|
+
const end = scanStringEnd(data, vstart);
|
|
107
|
+
if (end === -1) return INCOMPLETE; // cut inside the value — never trust a partial field
|
|
108
|
+
const after = skipWs(data, end + 1);
|
|
109
|
+
if (after >= data.length) return INCOMPLETE; // closed quote but no boundary proof — err closed
|
|
110
|
+
const boundary = data[after];
|
|
111
|
+
if (boundary !== ',' && boundary !== '}') return INCOMPLETE;
|
|
112
|
+
try {
|
|
113
|
+
return { complete: true, value: JSON.parse(data.slice(vstart, end + 1)) };
|
|
114
|
+
} catch {
|
|
115
|
+
return INCOMPLETE;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return INCOMPLETE;
|
|
119
|
+
}
|
|
@@ -98,14 +98,15 @@ bash:
|
|
|
98
98
|
approval: deny
|
|
99
99
|
- match: "dd if=/dev/*"
|
|
100
100
|
approval: deny
|
|
101
|
-
#
|
|
102
|
-
# (
|
|
103
|
-
#
|
|
104
|
-
#
|
|
105
|
-
#
|
|
106
|
-
#
|
|
107
|
-
|
|
108
|
-
|
|
101
|
+
# Generic `rm -rf <dir>` is intentionally NOT denied here — the
|
|
102
|
+
# block-dangerous hook (running via TASK-004's bridge) is the authority:
|
|
103
|
+
# its safe-cleanup allowlist (dist/build/coverage/.next/.nuxt/.turbo/
|
|
104
|
+
# tmp/temp/.cache/node_modules, realpath-contained in the project root)
|
|
105
|
+
# passes routine cleanup, and every other `rm -rf` is gated fail-closed
|
|
106
|
+
# (ask → block on omp, deny on claude). A static `rm -rf *` deny used to
|
|
107
|
+
# sit here but outranked that allowlist, so allowlisted cleanup could
|
|
108
|
+
# never run. The hard rm denies above (/, ~, ., ..) stay — those are
|
|
109
|
+
# catastrophic regardless of allowlist.
|
|
109
110
|
- match: "*"
|
|
110
111
|
approval: allow
|
|
111
112
|
|
|
@@ -32,7 +32,7 @@ import { resolveChainExecTimeoutMs } from '../../../.claude/ukit/runtime/hook-ch
|
|
|
32
32
|
|
|
33
33
|
export const HOOK_EVENT_MAP = {
|
|
34
34
|
tool_call: {
|
|
35
|
-
'Read|Grep|Glob': ['sensitive-data-guard.
|
|
35
|
+
'Read|Grep|Glob': ['sensitive-data-guard.mjs'],
|
|
36
36
|
'Edit|Write': [
|
|
37
37
|
'protect-files.sh',
|
|
38
38
|
'stale-spec-guard.sh',
|
|
@@ -43,19 +43,19 @@ export const HOOK_EVENT_MAP = {
|
|
|
43
43
|
],
|
|
44
44
|
Bash: [
|
|
45
45
|
'auto-allow-bash.sh',
|
|
46
|
-
'block-dangerous.
|
|
47
|
-
'sensitive-data-guard.
|
|
46
|
+
'block-dangerous.mjs',
|
|
47
|
+
'sensitive-data-guard.mjs',
|
|
48
48
|
'handoff-model-guard.sh',
|
|
49
49
|
'context-hardcap-gate.sh',
|
|
50
50
|
'verification-guard.sh',
|
|
51
51
|
],
|
|
52
52
|
},
|
|
53
53
|
tool_result: {
|
|
54
|
-
'Read|Grep|Glob': ['record-execution.
|
|
55
|
-
'Edit|Write': ['post-edit-verify.sh', 'record-execution.
|
|
56
|
-
Bash: ['compress-output.sh', 'record-execution.
|
|
54
|
+
'Read|Grep|Glob': ['record-execution.mjs'],
|
|
55
|
+
'Edit|Write': ['post-edit-verify.sh', 'record-execution.mjs', 'task-watchdog.sh'],
|
|
56
|
+
Bash: ['compress-output.sh', 'record-execution.mjs'],
|
|
57
57
|
},
|
|
58
|
-
before_agent_start: ['sensitive-data-guard.
|
|
58
|
+
before_agent_start: ['sensitive-data-guard.mjs', 'skill-router.sh', 'vision-router.sh', 'context-window-guard.sh'],
|
|
59
59
|
'session.compacting': ['reinject-context.sh'],
|
|
60
60
|
session_start: ['project-important.sh', 'auto-prune-bash.sh', 'reset-compact-pressure.sh', 'handoff-resume.sh'],
|
|
61
61
|
};
|
|
@@ -99,12 +99,18 @@ export const FAIL_CLOSED_SCRIPTS = new Set([
|
|
|
99
99
|
'context-hardcap-gate.sh',
|
|
100
100
|
'block-dangerous.sh',
|
|
101
101
|
'sensitive-data-guard.sh',
|
|
102
|
+
// TASK-005 (SPEC §FR-008): the three ported hooks now run as in-proc .mjs
|
|
103
|
+
// module steps; the .sh names stay registered for the thin-wrapper fallback.
|
|
104
|
+
'block-dangerous.mjs',
|
|
105
|
+
'sensitive-data-guard.mjs',
|
|
102
106
|
]);
|
|
103
107
|
|
|
104
108
|
export const ADVISORY_SCRIPTS = new Set([
|
|
105
109
|
'skill-router.sh',
|
|
106
110
|
'verification-guard.sh',
|
|
107
111
|
'record-execution.sh',
|
|
112
|
+
// TASK-005: record-execution now runs as an .mjs module step (advisory).
|
|
113
|
+
'record-execution.mjs',
|
|
108
114
|
'auto-allow-bash.sh',
|
|
109
115
|
'pre-edit-backup.sh',
|
|
110
116
|
'vision-router.sh',
|
|
@@ -130,7 +136,9 @@ function classifyFailure(scriptName) {
|
|
|
130
136
|
// must not create. One deliberate exception: block-dangerous.sh is the gate the user
|
|
131
137
|
// explicitly required to never fail open (destructive-command protection), so a Bash chain
|
|
132
138
|
// whose dangerous-command check timed out stays blocked with the honest reason.
|
|
133
|
-
|
|
139
|
+
// TASK-005: block-dangerous runs as an .mjs module step now — both names stay
|
|
140
|
+
// closed (the .sh thin wrapper is still invocable as a fallback path).
|
|
141
|
+
const TIMEOUT_STAYS_CLOSED = new Set(['block-dangerous.sh', 'block-dangerous.mjs']);
|
|
134
142
|
|
|
135
143
|
// TASK-018: the hook-chain-runner's failure taxonomy. Infrastructure outcomes
|
|
136
144
|
// (overflow / timeout / signal / budget-exhausted) produced NO verdict, so their
|
|
@@ -272,11 +280,11 @@ function translateExecResult(scriptName, execResult) {
|
|
|
272
280
|
// realistic, and that guard fails open. This is deliberate anti-freeze policy:
|
|
273
281
|
// a guard that produced NO verdict is an infrastructure event, and blocking on
|
|
274
282
|
// it froze every Edit|Write whenever the machine was slow. Only
|
|
275
|
-
// block-dangerous.sh stays closed (TIMEOUT_STAYS_CLOSED) because destructive-
|
|
283
|
+
// block-dangerous (.sh/.mjs) stays closed (TIMEOUT_STAYS_CLOSED) because destructive-
|
|
276
284
|
// command protection was explicitly required to never fail open. Stated in the
|
|
277
285
|
// warning so a skipped guard is never a silent one.
|
|
278
286
|
const failOpenTradeOff = FAIL_CLOSED_SCRIPTS.has(scriptName) && !TIMEOUT_STAYS_CLOSED.has(scriptName)
|
|
279
|
-
? ` ${scriptName} is a fail-closed guard, but a guard that never produced a verdict is treated as "could not verify" rather than a block — the deliberate anti-freeze trade-off for infrastructure events; only block-dangerous.sh stays closed.`
|
|
287
|
+
? ` ${scriptName} is a fail-closed guard, but a guard that never produced a verdict is treated as "could not verify" rather than a block — the deliberate anti-freeze trade-off for infrastructure events; only block-dangerous (.sh/.mjs) stays closed.`
|
|
280
288
|
: '';
|
|
281
289
|
return {
|
|
282
290
|
block: false,
|
|
@@ -590,11 +598,11 @@ export async function runScriptChain(
|
|
|
590
598
|
recordHookErrorDiagnostic(projectRoot, payload.session_id, diagnostic);
|
|
591
599
|
// TASK-031: a staged payload that was lost or corrupted mid-chain voids every
|
|
592
600
|
// verdict below it. Chains that must not fail open on an unverifiable verdict
|
|
593
|
-
// (Edit|Write transport policy, and block-dangerous
|
|
601
|
+
// (Edit|Write transport policy, and block-dangerous's never-fail-open rule)
|
|
594
602
|
// stay closed; everything else fails open loudly. Neither reason carries any
|
|
595
603
|
// payload content — only the classification and byte count.
|
|
596
604
|
const payloadStaysClosed = payloadProbe !== null
|
|
597
|
-
&& (failClosedOnTransportError || scripts.includes('block-dangerous.sh'));
|
|
605
|
+
&& (failClosedOnTransportError || scripts.includes('block-dangerous.sh') || scripts.includes('block-dangerous.mjs'));
|
|
598
606
|
if (payloadStaysClosed) {
|
|
599
607
|
const integrityReason = `UKit hook payload transport failed: the staged payload file was `
|
|
600
608
|
+ `${payloadProbe === 'missing' ? 'removed' : 'truncated'} before the chain could read it `
|