@ngockhoale/ukit 2.7.4 → 2.7.5

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.
@@ -126,359 +126,7 @@ else
126
126
  trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
127
127
  fi
128
128
 
129
- INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" SCRIPT_PATH="$SCRIPT_PATH" UKIT_HOOK_DEADLINE_MS="${UKIT_HOOK_DEADLINE_MS:-3000}" UKIT_SALVAGE_ACTIVE="${UKIT_SALVAGE_ACTIVE:-0}" UKIT_SALVAGED_EVENT="${UKIT_SALVAGED_EVENT:-}" UKIT_SALVAGED_TOOL_NAME="${UKIT_SALVAGED_TOOL_NAME:-}" UKIT_SALVAGED_FIELD_NAME="${UKIT_SALVAGED_FIELD_NAME:-}" UKIT_SALVAGED_FIELD_VALUE="${UKIT_SALVAGED_FIELD_VALUE:-}" node <<'NODE'
130
- const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
131
- // BUG-C21-03 (chosen posture: FAIL-CLOSED, parity with the staging refusal):
132
- // a wedged scan must never pass an uninspected payload. The deadline emits a
133
- // redacted §8 degrade reason and exits 2 — never a silent exit-0 pass.
134
- setTimeout(() => {
135
- try {
136
- process.stdout.write(`${JSON.stringify({
137
- systemMessage: 'UKit sensitive-data gate timed out before finishing its scan; blocked fail-closed rather than pass an uninspected payload.',
138
- })}\n`);
139
- } catch {}
140
- try {
141
- // stderr mirror: piped stdout is async and process.exit() can truncate the
142
- // pending write — without this the block can arrive with no visible reason.
143
- process.stderr.write('BLOCKED (sensitive data gate): scan timed out; blocked fail-closed rather than pass an uninspected payload.\n');
144
- } catch {}
145
- process.exit(2);
146
- }, HOOK_DEADLINE_MS).unref();
147
- const fs = require('fs');
148
- const fsp = fs.promises;
149
- const path = require('path');
150
- const { createHash } = require('crypto');
151
-
152
- // BUG-C21-04: every fs call below is async — a sync read on a stalled mount
153
- // parks the event loop and the deadline above could never fire.
154
- void (async () => {
155
- let rawInput = '';
156
- try { rawInput = await fsp.readFile(process.env.INPUT_FILE || '', 'utf8'); } catch {}
157
- const payload = (() => {
158
- try {
159
- const parsed = JSON.parse(rawInput);
160
- return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
161
- } catch {
162
- return {};
163
- }
164
- })();
165
-
166
- // TASK-003: on the degraded path the staged prefix cannot parse, so the shell
167
- // handed us the provably-COMPLETE salvaged fields — reconstruct just enough
168
- // payload for the normal channel scan below.
169
- if (process.env.UKIT_SALVAGE_ACTIVE === '1' && process.env.UKIT_SALVAGED_EVENT) {
170
- payload.hook_event_name = process.env.UKIT_SALVAGED_EVENT;
171
- if (process.env.UKIT_SALVAGED_EVENT === 'PreToolUse' && process.env.UKIT_SALVAGED_TOOL_NAME) {
172
- payload.tool_name = process.env.UKIT_SALVAGED_TOOL_NAME;
173
- payload.tool_input = {};
174
- if (process.env.UKIT_SALVAGED_FIELD_NAME) {
175
- payload.tool_input[process.env.UKIT_SALVAGED_FIELD_NAME] = process.env.UKIT_SALVAGED_FIELD_VALUE;
176
- }
177
- } else if (process.env.UKIT_SALVAGED_EVENT === 'UserPromptSubmit') {
178
- payload.prompt = process.env.UKIT_SALVAGED_FIELD_VALUE;
179
- }
180
- }
181
-
182
- const projectRoot = process.env.PROJECT_ROOT || process.cwd();
183
-
184
- async function readJsonSafe(filePath, fallback) {
185
- try {
186
- return JSON.parse(await fsp.readFile(filePath, 'utf8'));
187
- } catch {
188
- return fallback;
189
- }
190
- }
191
-
192
- const config = await readJsonSafe(path.join(projectRoot, '.ukit', 'storage', 'config.json'), null);
193
- if (config && config.security && config.security.sensitiveDataGate === false) {
194
- process.exit(0);
195
- }
196
-
197
- const allowlist = (await readJsonSafe(path.join(projectRoot, '.ukit', 'storage', 'security', 'allowlist.json'), null)) || {};
198
- const allowedValueHashes = new Set(
199
- Array.isArray(allowlist.values) ? allowlist.values.filter((v) => typeof v === 'string') : [],
200
- );
201
- const allowedPaths = Array.isArray(allowlist.paths) ? allowlist.paths.filter((p) => typeof p === 'string') : [];
202
-
203
- function sha256(value) {
204
- return createHash('sha256').update(value).digest('hex');
205
- }
206
-
207
- function pathAllowed(filePath) {
208
- const normalized = String(filePath).replace(/\\/g, '/').trim();
209
- let relative = normalized;
210
- try {
211
- relative = path.relative(projectRoot, normalized);
212
- } catch {
213
- // keep normalized
214
- }
215
- return allowedPaths.some((allowed) => allowed === normalized || allowed === relative);
216
- }
217
-
218
- // --- high-confidence secret value scan (TASK-039: shared runtime module) ---
219
- // The token-pattern definitions and matching live in the shared runtime
220
- // module ukit/runtime/sensitive-value-scanner.mjs (manifest requires:
221
- // ukit-runtime-scripts) — there is deliberately NO local pattern copy here, so
222
- // the hook and the PROJECT_IMPORTANT renderer can never drift apart. File/path
223
- // classification and Bash command shapes stay in this hook.
224
- // Leak-safety: the scanner returns labels only — never a value, excerpt, or
225
- // hash — so blocked-prompt previews can no longer print a value's truncated
226
- // sha256; the redacted block message says the value is withheld instead.
227
- const SCANNER_PATH = process.env.SCRIPT_PATH || '';
228
- let scannerModule = null;
229
- let scannerTried = false;
230
- async function loadScanner() {
231
- if (scannerTried) return scannerModule;
232
- scannerTried = true;
233
- try {
234
- const { pathToFileURL } = require('url');
235
- scannerModule = await import(pathToFileURL(SCANNER_PATH).href);
236
- } catch {
237
- scannerModule = null;
238
- }
239
- return scannerModule;
240
- }
241
-
242
- // The gate is fail-closed on detection AND on scanner availability: a missing
243
- // or crashing scanner must never silently bypass a channel that could carry a
244
- // secret (the module is shipped by ukit-runtime-scripts, which the manifest
245
- // declares this hook requires). Same redacted block shape as a detection.
246
- function blockScannerUnavailable() {
247
- process.stderr.write(
248
- 'BLOCKED (sensitive data gate unavailable): the shared scanner module '
249
- + 'ukit/runtime/sensitive-value-scanner.mjs is missing or failed to load, '
250
- + 'so this payload cannot be proven clean. Run `ukit install` to restore it.\n',
251
- );
252
- process.stdout.write(`${JSON.stringify({
253
- systemMessage: 'UKit blocked this call: the sensitive-data scanner module is unavailable (fail-closed). Run `ukit install`.',
254
- })}\n`);
255
- process.exit(2);
256
- }
257
-
258
- // --- secret file classification ---
259
- function classifySecretFile(filePath) {
260
- if (typeof filePath !== 'string' || !filePath.trim()) return null;
261
- const normalized = filePath.replace(/\\/g, '/').trim();
262
- const rawBase = normalized.split('/').pop();
263
- if (!rawBase) return null;
264
- const base = rawBase.toLowerCase();
265
- if (base.endsWith('.env.example')) return null;
266
- if (base === '.env' || base.startsWith('.env.')) return 'dotenv/secret env file';
267
- if (base.endsWith('.pub') || base.endsWith('.public')) return null;
268
- if (/^id_(rsa|dsa|ecdsa|ed25519)(\.|$)/.test(base)) return 'SSH private key';
269
- if (/\.(pem|key|p12|pfx|keystore|jks)$/.test(base)) return 'key/certificate file';
270
- if (/^credentials\.(json|ya?ml|txt|ini)$/.test(base)) return 'credentials file';
271
- if (/^service[_-]?account.*\.json$/.test(base)) return 'service-account key file';
272
- if (base === '.npmrc' || base === '.netrc' || base === '.htpasswd') return 'credentials file';
273
- if (/^secrets?\.(json|ya?ml|txt|ini|env)$/.test(base)) return 'secrets file';
274
- if (/(^|\/)\.aws\/credentials$/i.test(normalized)) return 'AWS credentials file';
275
- return null;
276
- }
277
-
278
- // --- bash command shapes that dump secrets ---
279
- const DUMP_VERBS = new Set([
280
- 'cat', 'head', 'tail', 'less', 'more', 'zless', 'zcat', 'xxd', 'strings',
281
- 'base64', 'od', 'hexdump', 'bat', 'grep', 'egrep', 'fgrep', 'rg', 'awk', 'sed',
282
- ]);
283
-
284
- function scanBashShapes(command) {
285
- const found = [];
286
- const segments = command.split(/\|\||&&|;|\||\n/);
287
- for (const rawSegment of segments) {
288
- const segment = rawSegment.trim();
289
- if (!segment) continue;
290
- const tokens = segment.split(/\s+/);
291
- const head = tokens[0].replace(/^["']+|["']+$/g, '');
292
-
293
- if ((head === 'env' || head === 'printenv' || head === 'history') && tokens.length === 1) {
294
- found.push({ label: `bare \`${head}\` dumps environment/history (may contain tokens)`, value: segment });
295
- continue;
296
- }
297
- if (head === 'export' && (tokens[1] === '-p' || tokens[1] === '--print')) {
298
- found.push({ label: 'bare `export -p` dumps all environment variables', value: segment });
299
- continue;
300
- }
301
-
302
- if (DUMP_VERBS.has(head)) {
303
- for (const token of tokens.slice(1)) {
304
- const cleaned = token.replace(/^["']+|["',:]+$/g, '');
305
- const kind = classifySecretFile(cleaned);
306
- if (kind && !pathAllowed(cleaned)) {
307
- found.push({ label: `${head} reads ${kind}`, value: cleaned, isPath: true });
308
- }
309
- }
310
- }
311
-
312
- // `-u/--user user:pass` is only credentials on tools that authenticate with it.
313
- // The same flag shape on docker/runuser/chown (`--user 1000:1000`, a uid:gid pair)
314
- // is NOT a secret — matching it anyway false-positives every containerised test run.
315
- const headBase = head.replace(/^.*\//, '');
316
- const CRED_USER_TOOLS = new Set(['curl', 'wget', 'ftp', 'lftp', 'aria2c', 'http', 'https']);
317
- const inlineUser = /(^|\s)(-u|--user)\s+["']?([^\s"':]+):([^\s"']+)/.exec(segment);
318
- if (inlineUser && CRED_USER_TOOLS.has(headBase)) {
319
- // Even on credential tools, a purely numeric pair is a uid:gid, not a password.
320
- const numericIds = /^\d+$/.test(inlineUser[3]) && /^\d+$/.test(inlineUser[4]);
321
- if (!numericIds) {
322
- found.push({ label: 'curl/wget inline credentials (-u user:password)', value: segment });
323
- }
324
- }
325
- if (/https?:\/\/[^\s/@'"]+:[^\s/@'"]+@/.test(segment)) {
326
- found.push({ label: 'URL with embedded credentials (user:pass@host)', value: segment });
327
- }
328
- }
329
- return found;
330
- }
331
-
332
- // --- channel wiring ---
333
- const event = String(payload.hook_event_name || '');
334
- const toolName = String(payload.tool_name || '');
335
- const toolInput = payload.tool_input && typeof payload.tool_input === 'object' ? payload.tool_input : {};
336
-
337
- const textChannels = [];
338
- const filePathChannels = [];
339
- let bashCommand = null;
340
-
341
- if (event === 'UserPromptSubmit') {
342
- const prompt = [payload.prompt, payload.user_prompt, payload.text].find(
343
- (candidate) => typeof candidate === 'string' && candidate.trim(),
344
- );
345
- if (prompt) textChannels.push({ channel: 'prompt', text: prompt });
346
- } else if (event === 'PreToolUse') {
347
- if (toolName === 'Read' && typeof toolInput.file_path === 'string') {
348
- filePathChannels.push(toolInput.file_path);
349
- } else if (toolName === 'Grep') {
350
- if (typeof toolInput.path === 'string' && toolInput.path.trim()) filePathChannels.push(toolInput.path);
351
- if (typeof toolInput.pattern === 'string') textChannels.push({ channel: 'grep pattern', text: toolInput.pattern });
352
- } else if (toolName === 'Bash' && typeof toolInput.command === 'string') {
353
- bashCommand = toolInput.command;
354
- }
355
- } else {
356
- process.exit(0);
357
- }
358
-
359
- // --- collect findings (async: the value scan delegates to the shared module) ---
360
- void (async () => {
361
- const findings = [];
362
-
363
- for (const filePath of filePathChannels) {
364
- const kind = classifySecretFile(filePath);
365
- if (kind && !pathAllowed(filePath)) {
366
- findings.push({ label: `Read of ${kind}`, preview: filePath, hashSource: filePath });
367
- }
368
- }
369
-
370
- // The value scan needs the shared scanner module only when a text/bash
371
- // channel exists to scan; a pure secret-file Read verdict never depends on it.
372
- const needsScanner = textChannels.length > 0 || bashCommand;
373
- let scanner = null;
374
- if (needsScanner) {
375
- scanner = await loadScanner();
376
- if (!scanner || typeof scanner.scanText !== 'function') {
377
- blockScannerUnavailable();
378
- }
379
- }
380
- const gateEnabled = scanner ? scanner.isSensitiveDataGateEnabled(config) !== false : true;
381
- const allowlistHashes = scanner ? scanner.loadSensitiveAllowlist({ security: { allowlist } }) : [];
382
-
383
- function scanTextLabels(text) {
384
- let verdict;
385
- try {
386
- verdict = scanner.scanText(text, { allowlistHashes, gateEnabled });
387
- } catch {
388
- blockScannerUnavailable();
389
- }
390
- return verdict && Array.isArray(verdict.labels) ? verdict.labels : [];
391
- }
392
-
393
- const seenLabels = new Set();
394
- for (const { channel, text } of textChannels) {
395
- for (const label of scanTextLabels(text)) {
396
- const key = `${channel}:${label}`;
397
- if (seenLabels.has(key)) continue;
398
- seenLabels.add(key);
399
- findings.push({ label: `${label} in ${channel}` });
400
- }
401
- }
402
-
403
- if (bashCommand) {
404
- for (const finding of scanBashShapes(bashCommand)) {
405
- if (finding.isPath) {
406
- findings.push({ label: `bash command touches ${finding.label}`, preview: finding.value });
407
- continue;
408
- }
409
- if (allowedValueHashes.has(sha256(finding.value))) continue;
410
- findings.push({ label: `${finding.label} in bash command`, value: finding.value });
411
- }
412
- for (const label of scanTextLabels(bashCommand)) {
413
- const key = `bash:${label}`;
414
- if (seenLabels.has(key)) continue;
415
- seenLabels.add(key);
416
- findings.push({ label: `${label} in bash command` });
417
- }
418
- }
419
-
420
- if (findings.length === 0) {
421
- process.exit(0);
422
- }
423
-
424
- // --- machine-generated envelope lane (UserPromptSubmit only) ---
425
- // Harness plumbing (<task-notification>, <system-reminder>,
426
- // <cross-session-message>) legitimately carries fixture/log content through
427
- // the prompt channel — test tokens quoted in task bodies, CI logs, agent
428
- // results. Blocking it silently drops the notification and stalls pipelines
429
- // ("Waiting for N background agents"). These envelopes flow with a redacted
430
- // advisory: labels + counts only, never values. Everything a human types —
431
- // and every other event/channel — stays fail-closed below.
432
- const MACHINE_ENVELOPE_RE = /^(?:task-notification|system-reminder|cross-session-message)$/i;
433
- function isCompleteMachineEnvelope(text) {
434
- const trimmed = String(text || '').trim();
435
- const opening = /^<([a-z-]+)(?:\s[^>]*)?>/i.exec(trimmed);
436
- if (!opening || !MACHINE_ENVELOPE_RE.test(opening[1])) return false;
437
- const tag = opening[1];
438
- const closing = new RegExp(`</${tag}>$`, 'i');
439
- return closing.test(trimmed);
440
- }
441
- const promptText = textChannels.length > 0 ? textChannels[0].text : null;
442
- if (event === 'UserPromptSubmit' && promptText && isCompleteMachineEnvelope(promptText)) {
443
- const labels = [...new Set(findings.map((f) => f.label))];
444
- process.stdout.write(`${JSON.stringify({
445
- systemMessage: `UKit sensitive-data gate: ${findings.length} secret-shaped value(s) inside a machine-generated notification (${labels.slice(0, 3).join('; ')}${labels.length > 3 ? '; …' : ''}). Delivered without blocking — treat as fixture/log plumbing, not a typed secret. Never repeat the values in your reply.`,
446
- })}\n`);
447
- process.exit(0);
448
- }
449
-
450
- // --- block message: redacted previews only, never the secret itself ---
451
- // The shared scanner returns labels only, so value findings can no longer show
452
- // a truncated sha256 — the value stays withheld entirely (leak-safe contract).
453
- function describeFinding(finding) {
454
- const where = finding.preview ? `${finding.preview}` : '(value withheld)';
455
- return ` - ${finding.label}: ${where}`;
456
- }
457
-
458
- const lines = [
459
- `BLOCKED (sensitive data): ${findings.length} potential secret(s) detected. Nothing was sent to the AI.`,
460
- ...findings.map(describeFinding),
461
- 'Detection is format-true only: a match must itself be a vendor-formatted key,',
462
- 'a private key block, a JWT, or a credentials file. Code, prose, hashes and',
463
- 'encoded blobs are not blocked. To proceed, the USER can choose one of:',
464
- ' 1. Redact the secret (placeholder like <API_KEY>) and retry.',
465
- ' 2. Approve it explicitly: add the full sha256 (shasum -a 256 of the value) or the file path',
466
- ' to .ukit/storage/security/allowlist.json — that file is protected from AI edits.',
467
- ' 3. Disable the gate: security.sensitiveDataGate=false in .ukit/storage/config.json (not recommended).',
468
- 'Never repeat or guess the detected value in your reply. Ask the user instead.',
469
- ];
470
- process.stderr.write(`${lines.join('\n')}\n`);
471
- // stderr reaches the model; the user needs a stop reason they can actually see. Emit a
472
- // structured systemMessage (redacted — labels only, never the matched value) so a
473
- // sensitive-data block never looks like a silent stall in the UI.
474
- process.stdout.write(`${JSON.stringify({
475
- systemMessage: `UKit blocked this call: ${findings.length} potential secret(s) detected (${findings.map((f) => f.label).slice(0, 3).join('; ')}${findings.length > 3 ? '; …' : ''}). Redact the value, allowlist its sha256, or ask the user to decide.`,
476
- })}\n`);
477
- process.exit(2);
478
- })();
479
- })();
480
- NODE
481
-
129
+ INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" SCRIPT_PATH="$SCRIPT_PATH" UKIT_HOOK_DEADLINE_MS="${UKIT_HOOK_DEADLINE_MS:-3000}" UKIT_SALVAGE_ACTIVE="${UKIT_SALVAGE_ACTIVE:-0}" UKIT_SALVAGED_EVENT="${UKIT_SALVAGED_EVENT:-}" UKIT_SALVAGED_TOOL_NAME="${UKIT_SALVAGED_TOOL_NAME:-}" UKIT_SALVAGED_FIELD_NAME="${UKIT_SALVAGED_FIELD_NAME:-}" UKIT_SALVAGED_FIELD_VALUE="${UKIT_SALVAGED_FIELD_VALUE:-}" node "$HOOK_DIR/sensitive-data-guard.mjs"
482
130
  NODE_RC=$?
483
131
  # BUG-C21-03: a node crash (any non-blocking non-zero exit) means the payload
484
132
  # was never fully scanned — fail-closed parity: announce + block, never a
@@ -65,7 +65,7 @@
65
65
  "hooks": [
66
66
  {
67
67
  "type": "command",
68
- "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/sensitive-data-guard.sh\":8",
68
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/sensitive-data-guard.mjs\":8",
69
69
  "timeout": 18
70
70
  }
71
71
  ]
@@ -85,7 +85,7 @@
85
85
  "hooks": [
86
86
  {
87
87
  "type": "command",
88
- "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/auto-allow-bash.sh\":15 \"$CLAUDE_PROJECT_DIR/.claude/hooks/block-dangerous.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/sensitive-data-guard.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/handoff-model-guard.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/context-hardcap-gate.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/verification-guard.sh\":8",
88
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/auto-allow-bash.sh\":15 \"$CLAUDE_PROJECT_DIR/.claude/hooks/block-dangerous.mjs\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/sensitive-data-guard.mjs\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/handoff-model-guard.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/context-hardcap-gate.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/verification-guard.sh\":8",
89
89
  "timeout": 65
90
90
  }
91
91
  ]
@@ -97,7 +97,7 @@
97
97
  "hooks": [
98
98
  {
99
99
  "type": "command",
100
- "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/record-execution.sh\":8",
100
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/record-execution.mjs\":8",
101
101
  "timeout": 18
102
102
  }
103
103
  ]
@@ -107,7 +107,7 @@
107
107
  "hooks": [
108
108
  {
109
109
  "type": "command",
110
- "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/post-edit-verify.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/record-execution.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/task-watchdog.sh\":8",
110
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/post-edit-verify.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/record-execution.mjs\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/task-watchdog.sh\":8",
111
111
  "timeout": 34
112
112
  }
113
113
  ]
@@ -117,7 +117,7 @@
117
117
  "hooks": [
118
118
  {
119
119
  "type": "command",
120
- "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/compress-output.sh\":12 \"$CLAUDE_PROJECT_DIR/.claude/hooks/record-execution.sh\":8",
120
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/compress-output.sh\":12 \"$CLAUDE_PROJECT_DIR/.claude/hooks/record-execution.mjs\":8",
121
121
  "timeout": 30
122
122
  }
123
123
  ]
@@ -128,7 +128,7 @@
128
128
  "hooks": [
129
129
  {
130
130
  "type": "command",
131
- "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/sensitive-data-guard.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/skill-router.sh\":20 \"$CLAUDE_PROJECT_DIR/.claude/hooks/vision-router.sh\":12 \"$CLAUDE_PROJECT_DIR/.claude/hooks/context-window-guard.sh\":10",
131
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/sensitive-data-guard.mjs\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/skill-router.sh\":20 \"$CLAUDE_PROJECT_DIR/.claude/hooks/vision-router.sh\":12 \"$CLAUDE_PROJECT_DIR/.claude/hooks/context-window-guard.sh\":10",
132
132
  "timeout": 60
133
133
  }
134
134
  ]
@@ -209,14 +209,30 @@ export async function sweepExecLedgerDir(dir, {
209
209
  // bound. Announce the first failure and then every 25th, C22 rate-limit
210
210
  // posture. writeSync(1) because a same-tick exit can drop a buffered write.
211
211
  let sweepFailureCount = 0;
212
+ // TASK-004 (C40, SPEC §FR-004): in-proc chain steps must never write fd 1 —
213
+ // their messages have to travel back through the step's returned stdout so the
214
+ // runner routes them into the right channel. record-execution.mjs sets this
215
+ // emitter for the duration of its call and resets it in `finally`; when unset,
216
+ // the legacy writeSync(1) posture is unchanged for .sh/CLI callers.
217
+ let ledgerMessageEmitter = null;
218
+ export function setLedgerMessageEmitter(fn) {
219
+ ledgerMessageEmitter = typeof fn === 'function' ? fn : null;
220
+ }
212
221
  export function noteSweepFailure(error) {
213
222
  sweepFailureCount += 1;
214
223
  if (sweepFailureCount !== 1 && sweepFailureCount % 25 !== 0) return null;
215
224
  const message =
216
225
  `UKit execution-ledger: dir sweep failed (${sweepFailureCount} failure(s) so far) — `
217
226
  + `the committed event is safe but the backlog bound may lag. Cause: ${error?.message || error}`;
227
+ const line = `${JSON.stringify({ systemMessage: message })}\n`;
228
+ if (ledgerMessageEmitter) {
229
+ try {
230
+ ledgerMessageEmitter(line);
231
+ } catch { /* an emitter failure never loses the ledger event */ }
232
+ return message;
233
+ }
218
234
  try {
219
- fsSync.writeSync(1, `${JSON.stringify({ systemMessage: message })}\n`);
235
+ fsSync.writeSync(1, line);
220
236
  } catch { /* stdout already gone */ }
221
237
  return message;
222
238
  }
@@ -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 result = await runHookProcess({
180
- command: scriptPath,
181
- deadlineMs: Math.min(childBudgetsMs[scriptIndex], remainingMs),
182
- args: [],
183
- input: payloadText,
184
- maxBuffer: MAX_BUFFER_BYTES,
185
- cwd: projectRoot,
186
- // TASK-223 (HK-401): mark chain-spawned children so their structured
187
- // permission decisions keep the omp contract (stdout parsed on exit 2).
188
- // Direct Claude Code invocations carry no marker and exit 0 instead —
189
- // a non-zero exit there discards stdout, killing the decision JSON.
190
- env: {
191
- ...process.env,
192
- CLAUDE_PROJECT_DIR: projectRoot,
193
- // TASK-234: the marker selects the omp structured-decision contract
194
- // (ask + exit 2). Under --emit-verdict the runner replays the direct
195
- // Claude contract (deny + exit 0), so children must NOT see it.
196
- ...(chainMarker ? { UKIT_HOOK_CHAIN_RUNNER: '1' } : {}),
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
- const code = Number.isFinite(result.code) ? result.code : 1;
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
- if (last.code === 2) {
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 (lastIsFailClosed && lastFailedToVerdict) {
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);