@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.
@@ -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
@@ -48,7 +48,6 @@
48
48
  "Bash(rm -rf ~:*)",
49
49
  "Bash(rm -rf ..:*)",
50
50
  "Bash(rm -rf .:*)",
51
- "Bash(rm -rf:*)",
52
51
  "Bash(dd if=/dev/:*)",
53
52
  "Bash(*> /dev/sda:*)",
54
53
  "Bash(mkfs:*)",
@@ -65,7 +64,7 @@
65
64
  "hooks": [
66
65
  {
67
66
  "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",
67
+ "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
68
  "timeout": 18
70
69
  }
71
70
  ]
@@ -85,7 +84,7 @@
85
84
  "hooks": [
86
85
  {
87
86
  "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",
87
+ "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
88
  "timeout": 65
90
89
  }
91
90
  ]
@@ -97,7 +96,7 @@
97
96
  "hooks": [
98
97
  {
99
98
  "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",
99
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/record-execution.mjs\":8",
101
100
  "timeout": 18
102
101
  }
103
102
  ]
@@ -107,7 +106,7 @@
107
106
  "hooks": [
108
107
  {
109
108
  "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",
109
+ "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
110
  "timeout": 34
112
111
  }
113
112
  ]
@@ -117,7 +116,7 @@
117
116
  "hooks": [
118
117
  {
119
118
  "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",
119
+ "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
120
  "timeout": 30
122
121
  }
123
122
  ]
@@ -128,7 +127,7 @@
128
127
  "hooks": [
129
128
  {
130
129
  "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",
130
+ "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
131
  "timeout": 60
133
132
  }
134
133
  ]
@@ -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
  }