@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.
- package/CHANGELOG.md +38 -0
- package/manifests/platform.full.yaml +43 -0
- package/package.json +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 -6
- 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/hooks/pre/ukit-bridge.js +20 -12
|
@@ -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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
|
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
|
|
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);
|