@ngockhoale/ukit 2.4.3 → 2.5.1

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.
@@ -2,12 +2,158 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import {
4
4
  cleanupEmptyParents,
5
+ copyFileRawExclusive,
5
6
  readJsonIfExists,
6
7
  removeLinkOrDir,
7
8
  removeLinkOnly,
8
9
  resolveProjectRelativePath,
9
10
  } from './fileOps.js';
10
11
  import { removeGitignoreBlock } from './ensureGitignore.js';
12
+ import { PROJECT_IMPORTANT_FILENAME } from './projectImportant.js';
13
+
14
+ // PROJECT_IMPORTANT.md is owner content, never UKit-managed. It must NEVER appear
15
+ // in ALLOWED_UNINSTALL_PREFIXES and is never deleted/renamed/chmodded/rewritten/
16
+ // tracked/followed. Before any destructive uninstall step a regular source gets a
17
+ // raw-byte backup beside it at the project root (never under .claude/.ukit/.codex/
18
+ // .omp — those may be deleted): `<name>.ukit-backup`, then `.ukit-backup.1`, `.2`, …
19
+ // via bounded exclusive-create collision walk. See docs/PROJECT_IMPORTANT_SPEC.md §15.
20
+ const IMPORTANT_BACKUP_SUFFIX = '.ukit-backup';
21
+ const IMPORTANT_BACKUP_COLLISION_LIMIT = 100;
22
+
23
+ function importantBackupCandidateName(index) {
24
+ return index === 0
25
+ ? `${PROJECT_IMPORTANT_FILENAME}${IMPORTANT_BACKUP_SUFFIX}`
26
+ : `${PROJECT_IMPORTANT_FILENAME}${IMPORTANT_BACKUP_SUFFIX}.${index}`;
27
+ }
28
+
29
+ // Read-only inspection used by BOTH dry-run and real runs: resolves which backup
30
+ // candidate would be used without creating anything. An existing backup whose
31
+ // bytes already match the source is reused; a different-bytes or non-regular
32
+ // occupant pushes the walk to the next suffix. Nothing is ever overwritten.
33
+ async function planImportantBackup(projectRoot) {
34
+ const sourcePath = path.join(projectRoot, PROJECT_IMPORTANT_FILENAME);
35
+ const plan = {
36
+ sourcePath,
37
+ sourceBytes: null,
38
+ sourceExists: false,
39
+ sourceIsRegular: false,
40
+ wouldBackup: false,
41
+ backupPath: null,
42
+ reused: false,
43
+ exhausted: false,
44
+ preservedPaths: [],
45
+ backupWarnings: [],
46
+ };
47
+
48
+ let stat;
49
+ try {
50
+ stat = await fs.lstat(sourcePath); // lstat: never follow a source symlink
51
+ } catch {
52
+ return plan; // no source — nothing to preserve or back up
53
+ }
54
+ plan.sourceExists = true;
55
+ plan.preservedPaths.push(sourcePath);
56
+
57
+ if (!stat.isFile() || stat.isSymbolicLink()) {
58
+ // Symlink/non-regular source: never followed or copied — preserved in place.
59
+ plan.backupWarnings.push(
60
+ `[UKit] ${PROJECT_IMPORTANT_FILENAME} is not a regular file; preserved in place without a backup. Run \`ukit doctor\`.`,
61
+ );
62
+ return plan;
63
+ }
64
+ plan.sourceIsRegular = true;
65
+ plan.sourceBytes = await fs.readFile(sourcePath); // raw bytes, no decode
66
+
67
+ for (let index = 0; index <= IMPORTANT_BACKUP_COLLISION_LIMIT; index += 1) {
68
+ const candidatePath = path.join(projectRoot, importantBackupCandidateName(index));
69
+ let candidateStat;
70
+ try {
71
+ candidateStat = await fs.lstat(candidatePath);
72
+ } catch {
73
+ candidateStat = null;
74
+ }
75
+ if (!candidateStat) {
76
+ plan.wouldBackup = true;
77
+ plan.backupPath = candidatePath;
78
+ return plan;
79
+ }
80
+ if (candidateStat.isFile() && !candidateStat.isSymbolicLink()) {
81
+ const existingBytes = await fs.readFile(candidatePath).catch(() => null);
82
+ if (existingBytes && existingBytes.equals(plan.sourceBytes)) {
83
+ plan.backupPath = candidatePath;
84
+ plan.reused = true;
85
+ return plan;
86
+ }
87
+ }
88
+ // different bytes or non-regular — try next suffix
89
+ }
90
+
91
+ plan.exhausted = true;
92
+ plan.backupWarnings.push(
93
+ `[UKit] Backup name space exhausted (${IMPORTANT_BACKUP_COLLISION_LIMIT + 1} candidates in use); `
94
+ + `${PROJECT_IMPORTANT_FILENAME} preserved in place without a backup. Free a backup slot and rerun \`ukit uninstall\`.`,
95
+ );
96
+ return plan;
97
+ }
98
+
99
+ // Real-run backup: byte-identical raw copy via exclusive create (COPYFILE_EXCL —
100
+ // never overwrites). EEXIST means the slot filled between plan and copy; the walk
101
+ // re-checks it (equal bytes → reuse, else next suffix). Any other failure cleans
102
+ // up a possible partial copy and ABORTS uninstall before managed deletion.
103
+ async function createImportantBackup(projectRoot, sourcePath, sourceBytes) {
104
+ for (let index = 0; index <= IMPORTANT_BACKUP_COLLISION_LIMIT; index += 1) {
105
+ const candidatePath = path.join(projectRoot, importantBackupCandidateName(index));
106
+ let candidateStat;
107
+ try {
108
+ candidateStat = await fs.lstat(candidatePath);
109
+ } catch {
110
+ candidateStat = null;
111
+ }
112
+ if (candidateStat) {
113
+ if (candidateStat.isFile() && !candidateStat.isSymbolicLink()) {
114
+ const existingBytes = await fs.readFile(candidatePath).catch(() => null);
115
+ if (existingBytes && existingBytes.equals(sourceBytes)) {
116
+ return candidatePath; // reused — already byte-identical
117
+ }
118
+ }
119
+ continue;
120
+ }
121
+ try {
122
+ await copyFileRawExclusive(sourcePath, candidatePath); // preserves mode where supported
123
+ return candidatePath;
124
+ } catch (error) {
125
+ if (error?.code === 'EEXIST') {
126
+ // Lost the race — the slot now exists. Reuse it when its bytes match the
127
+ // source; otherwise advance to the next suffix.
128
+ try {
129
+ const racedStat = await fs.lstat(candidatePath);
130
+ if (racedStat.isFile() && !racedStat.isSymbolicLink()) {
131
+ const racedBytes = await fs.readFile(candidatePath).catch(() => null);
132
+ if (racedBytes && racedBytes.equals(sourceBytes)) {
133
+ return candidatePath;
134
+ }
135
+ }
136
+ } catch {
137
+ // vanished again — either way, move on
138
+ }
139
+ continue;
140
+ }
141
+ try {
142
+ await fs.rm(candidatePath, { force: true }); // clean partial backup
143
+ } catch {
144
+ // best-effort cleanup only
145
+ }
146
+ // Error carries no source content — only the failing operation.
147
+ throw new Error(
148
+ `[UKit] Uninstall aborted: could not back up ${PROJECT_IMPORTANT_FILENAME} (${error?.code || error?.message}). Managed paths were not removed.`,
149
+ { cause: error },
150
+ );
151
+ }
152
+ }
153
+ throw new Error(
154
+ `[UKit] Uninstall aborted: no free backup name for ${PROJECT_IMPORTANT_FILENAME} after ${IMPORTANT_BACKUP_COLLISION_LIMIT} collisions. Managed paths were not removed.`,
155
+ );
156
+ }
11
157
 
12
158
  // Use lstat (not access) so broken symlinks are also detected as existing.
13
159
  async function pathExistsLstat(targetPath) {
@@ -254,13 +400,42 @@ export async function uninstallUkit({ projectRoot, dryRun = false }) {
254
400
  );
255
401
  }
256
402
 
403
+ // PROJECT_IMPORTANT.md preservation — read-only plan first (spec §15). The
404
+ // source is never touched: only inspected via lstat + readFile.
405
+ const importantPlan = await planImportantBackup(projectRoot);
406
+
257
407
  if (dryRun) {
258
- // Use lstat (not access) so broken symlinks are included in the report.
408
+ // Dry run creates nothing: report the collision-resolved candidate and the
409
+ // preserved/refused state only. lstat (not access) keeps broken symlinks visible.
259
410
  const wouldRemove = [];
260
411
  for (const { abs } of safeEntries) {
261
412
  if (await pathExistsLstat(abs)) wouldRemove.push(abs);
262
413
  }
263
- return { removed: 0, attempted: allEntries.length, wasInstalled: true, wouldRemove, skippedSymlinkParents };
414
+ return {
415
+ removed: 0,
416
+ attempted: allEntries.length,
417
+ wasInstalled: true,
418
+ wouldRemove,
419
+ wouldBackup: importantPlan.wouldBackup,
420
+ backupPaths: importantPlan.backupPath ? [importantPlan.backupPath] : [],
421
+ preservedPaths: importantPlan.preservedPaths,
422
+ backupWarnings: importantPlan.backupWarnings,
423
+ skippedSymlinkParents,
424
+ };
425
+ }
426
+
427
+ // Backup runs BEFORE any destructive step. A failure here aborts uninstall —
428
+ // managed paths stay intact and the source is never modified.
429
+ const backupPaths = [];
430
+ if (importantPlan.wouldBackup) {
431
+ const backupPath = await createImportantBackup(
432
+ projectRoot,
433
+ importantPlan.sourcePath,
434
+ importantPlan.sourceBytes,
435
+ );
436
+ backupPaths.push(backupPath);
437
+ } else if (importantPlan.backupPath && importantPlan.reused) {
438
+ backupPaths.push(importantPlan.backupPath); // existing byte-identical backup
264
439
  }
265
440
 
266
441
  // Remove all paths in parallel
@@ -289,5 +464,14 @@ export async function uninstallUkit({ projectRoot, dryRun = false }) {
289
464
  await cleanupEmptyParents(removedPath, projectRoot);
290
465
  }
291
466
 
292
- return { removed, attempted: allEntries.length, wasInstalled: true, skippedSymlinkParents };
467
+ return {
468
+ removed,
469
+ attempted: allEntries.length,
470
+ wasInstalled: true,
471
+ wouldBackup: importantPlan.wouldBackup,
472
+ backupPaths,
473
+ preservedPaths: importantPlan.preservedPaths,
474
+ backupWarnings: importantPlan.backupWarnings,
475
+ skippedSymlinkParents,
476
+ };
293
477
  }
@@ -0,0 +1,67 @@
1
+ #!/bin/bash
2
+ # SessionStart hook: emit the deterministic PROJECT_IMPORTANT.md mandate envelope
3
+ # once per context epoch (docs/PROJECT_IMPORTANT_SPEC.md §6, §7, §9).
4
+ # ADVISORY ONLY — always exit 0. A missing runtime or a renderer failure degrades
5
+ # to the frozen `unreadable` warning line, never a raw exception, never a blocked
6
+ # session. No renderer logic lives here — the shared runtime module renders.
7
+
8
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
+ # shellcheck source=/dev/null
10
+ if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
11
+ # Arm advisory latency telemetry when available; a missing runtime (pre-install
12
+ # tree) simply leaves telemetry off.
13
+ source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
14
+ trap ukit_cleanup_hook_input EXIT
15
+ # Bounded stdin (H01): the SessionStart payload is unused, but it still flows
16
+ # through the staging bound so a producer that never closes the pipe cannot
17
+ # hold this hook open (R4.5).
18
+ ukit_stage_hook_input 1048576 truncate || exit 0
19
+ else
20
+ # Runtime helper missing (pre-install tree): the SAME bounded staging,
21
+ # inline — read cap+1 in the background, bound the wait, never drain with an
22
+ # unconditional `cat`.
23
+ UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
24
+ if [ -e /dev/fd/0 ]; then
25
+ exec 8<&0
26
+ head -c 1048577 <&8 > "$UKIT_INPUT_FILE" 2>/dev/null &
27
+ else
28
+ head -c 1048577 > "$UKIT_INPUT_FILE" 2>/dev/null &
29
+ fi
30
+ __ukit_reader=$!
31
+ __ukit_stage_ms="${UKIT_HOOK_STAGE_MS:-2000}"
32
+ case "$__ukit_stage_ms" in
33
+ ''|*[!0-9]*) __ukit_stage_ms=2000 ;;
34
+ esac
35
+ [ "$__ukit_stage_ms" -gt 0 ] 2>/dev/null || __ukit_stage_ms=2000
36
+ printf -v __ukit_stage_s '%d.%03d' $((__ukit_stage_ms / 1000)) $((__ukit_stage_ms % 1000))
37
+ ( sleep "$__ukit_stage_s" 2>/dev/null; kill -9 "$__ukit_reader" 2>/dev/null ) <&- >/dev/null 2>&1 &
38
+ __ukit_waiter=$!
39
+ wait "$__ukit_reader" 2>/dev/null
40
+ kill "$__ukit_waiter" 2>/dev/null
41
+ wait "$__ukit_waiter" 2>/dev/null
42
+ exec 8<&-
43
+ trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
44
+ fi
45
+
46
+ PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
47
+ SCRIPT="$PROJECT_ROOT/.claude/ukit/runtime/project-important.mjs"
48
+ UNREADABLE_WARNING='[UKit] PROJECT_IMPORTANT.md was not injected: the file could not be read. Run `ukit doctor`.'
49
+
50
+ if [ ! -f "$SCRIPT" ]; then
51
+ printf '%s\n' "$UNREADABLE_WARNING"
52
+ exit 0
53
+ fi
54
+
55
+ # The wrapped runtime self-arms on UKIT_HOOK_DEADLINE_MS (SCRIPT_ARM/EXIT_ARM),
56
+ # so a wedged read exits 0 inside the deadline instead of orphaning a node child.
57
+ INPUT="$(cat "$UKIT_INPUT_FILE" 2>/dev/null)"
58
+ OUTPUT=$(printf '%s' "$INPUT" | UKIT_PROJECT_ROOT="$PROJECT_ROOT" UKIT_HARNESS=claude-code UKIT_HOOK_DEADLINE_MS="${UKIT_HOOK_DEADLINE_MS:-3000}" node "$SCRIPT" 2>/dev/null)
59
+ STATUS=$?
60
+
61
+ if [ "$STATUS" -ne 0 ] || [ -z "$OUTPUT" ]; then
62
+ printf '%s\n' "$UNREADABLE_WARNING"
63
+ exit 0
64
+ fi
65
+
66
+ printf '%s\n' "$OUTPUT"
67
+ exit 0
@@ -27,6 +27,8 @@
27
27
  # a broken gate must not brick every session, but every real detection blocks.
28
28
 
29
29
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
30
+ HOOK_DIR="$SCRIPT_DIR"
31
+ SCRIPT_PATH="$HOOK_DIR/../ukit/runtime/sensitive-value-scanner.mjs"
30
32
  PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
31
33
  # Bounded stdin (H01) — fail-closed overflow: an oversized payload is REFUSED
32
34
  # (silent exit 2, nothing staged, nothing echoed). The gate never truncates
@@ -78,7 +80,7 @@ else
78
80
  trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
79
81
  fi
80
82
 
81
- INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE'
83
+ INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" SCRIPT_PATH="$SCRIPT_PATH" UKIT_HOOK_DEADLINE_MS="${UKIT_HOOK_DEADLINE_MS:-3000}" node <<'NODE'
82
84
  const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
83
85
  setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
84
86
  const fs = require('fs');
@@ -132,37 +134,44 @@ function pathAllowed(filePath) {
132
134
  return allowedPaths.some((allowed) => allowed === normalized || allowed === relative);
133
135
  }
134
136
 
135
- // --- high-confidence secret value patterns (shared by prompt + bash scanning) ---
136
- // AKIA/ASIA/AIza prefixes are pure base64-compatible text, so an encoded blob
137
- // can contain a coincidental key-shaped substring (round-17 live case: ASIA…
138
- // inside a base64 payload). Those rules must stand alone — no base64/base64url
139
- // character on either side. Padding "=" is a boundary: it only ends a blob.
140
- const TOKEN_PATTERNS = [
141
- { label: 'OpenAI/Anthropic-style API key', re: /\bsk-(?:proj-|ant-|svc-|acct-|admin-)?[A-Za-z0-9_-]{20,}/g },
142
- { label: 'AWS access key id', re: /(?<![A-Za-z0-9+/_-])(?:AKIA|ASIA)[0-9A-Z]{16}(?![A-Za-z0-9+/_-])/g },
143
- { label: 'GitHub token', re: /\bgh[pousr]_[A-Za-z0-9]{30,}\b/g },
144
- { label: 'GitHub fine-grained token', re: /\bgithub_pat_[A-Za-z0-9_]{20,}/g },
145
- { label: 'GitLab token', re: /\bglpat-[A-Za-z0-9_-]{20,}/g },
146
- { label: 'Slack token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}/g },
147
- { label: 'Google API key', re: /(?<![A-Za-z0-9+/_-])AIza[0-9A-Za-z_-]{20,}(?![A-Za-z0-9+/_-])/g },
148
- { label: 'Stripe live key', re: /\b[srp]k_live_[A-Za-z0-9]{20,}/g },
149
- { label: 'JWT', re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g },
150
- { label: 'private key block', re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----/g },
151
- ];
152
-
153
- // No keyword-assignment lane ("password: …", "token = …") on purpose — it was
154
- // the source of every false positive: it guessed at whatever followed a
155
- // keyword, blocking code, prose and shell syntax that is never a secret.
156
- function scanSecretText(text) {
157
- const found = [];
158
- for (const { label, re } of TOKEN_PATTERNS) {
159
- re.lastIndex = 0;
160
- let match;
161
- while ((match = re.exec(text)) !== null) {
162
- found.push({ label, value: match[0] });
163
- }
137
+ // --- high-confidence secret value scan (TASK-039: shared runtime module) ---
138
+ // The token-pattern definitions and matching live in the shared runtime
139
+ // module ukit/runtime/sensitive-value-scanner.mjs (manifest requires:
140
+ // ukit-runtime-scripts) — there is deliberately NO local pattern copy here, so
141
+ // the hook and the PROJECT_IMPORTANT renderer can never drift apart. File/path
142
+ // classification and Bash command shapes stay in this hook.
143
+ // Leak-safety: the scanner returns labels only — never a value, excerpt, or
144
+ // hash — so blocked-prompt previews can no longer print a value's truncated
145
+ // sha256; the redacted block message says the value is withheld instead.
146
+ const SCANNER_PATH = process.env.SCRIPT_PATH || '';
147
+ let scannerModule = null;
148
+ let scannerTried = false;
149
+ async function loadScanner() {
150
+ if (scannerTried) return scannerModule;
151
+ scannerTried = true;
152
+ try {
153
+ const { pathToFileURL } = require('url');
154
+ scannerModule = await import(pathToFileURL(SCANNER_PATH).href);
155
+ } catch {
156
+ scannerModule = null;
164
157
  }
165
- return found;
158
+ return scannerModule;
159
+ }
160
+
161
+ // The gate is fail-closed on detection AND on scanner availability: a missing
162
+ // or crashing scanner must never silently bypass a channel that could carry a
163
+ // secret (the module is shipped by ukit-runtime-scripts, which the manifest
164
+ // declares this hook requires). Same redacted block shape as a detection.
165
+ function blockScannerUnavailable() {
166
+ process.stderr.write(
167
+ 'BLOCKED (sensitive data gate unavailable): the shared scanner module '
168
+ + 'ukit/runtime/sensitive-value-scanner.mjs is missing or failed to load, '
169
+ + 'so this payload cannot be proven clean. Run `ukit install` to restore it.\n',
170
+ );
171
+ process.stdout.write(`${JSON.stringify({
172
+ systemMessage: 'UKit blocked this call: the sensitive-data scanner module is unavailable (fail-closed). Run `ukit install`.',
173
+ })}\n`);
174
+ process.exit(2);
166
175
  }
167
176
 
168
177
  // --- secret file classification ---
@@ -266,7 +275,8 @@ if (event === 'UserPromptSubmit') {
266
275
  process.exit(0);
267
276
  }
268
277
 
269
- // --- collect findings ---
278
+ // --- collect findings (async: the value scan delegates to the shared module) ---
279
+ void (async () => {
270
280
  const findings = [];
271
281
 
272
282
  for (const filePath of filePathChannels) {
@@ -276,14 +286,36 @@ for (const filePath of filePathChannels) {
276
286
  }
277
287
  }
278
288
 
279
- const seenValues = new Set();
289
+ // The value scan needs the shared scanner module only when a text/bash
290
+ // channel exists to scan; a pure secret-file Read verdict never depends on it.
291
+ const needsScanner = textChannels.length > 0 || bashCommand;
292
+ let scanner = null;
293
+ if (needsScanner) {
294
+ scanner = await loadScanner();
295
+ if (!scanner || typeof scanner.scanText !== 'function') {
296
+ blockScannerUnavailable();
297
+ }
298
+ }
299
+ const gateEnabled = scanner ? scanner.isSensitiveDataGateEnabled(config) !== false : true;
300
+ const allowlistHashes = scanner ? scanner.loadSensitiveAllowlist({ security: { allowlist } }) : [];
301
+
302
+ function scanTextLabels(text) {
303
+ let verdict;
304
+ try {
305
+ verdict = scanner.scanText(text, { allowlistHashes, gateEnabled });
306
+ } catch {
307
+ blockScannerUnavailable();
308
+ }
309
+ return verdict && Array.isArray(verdict.labels) ? verdict.labels : [];
310
+ }
311
+
312
+ const seenLabels = new Set();
280
313
  for (const { channel, text } of textChannels) {
281
- for (const finding of scanSecretText(text)) {
282
- if (allowedValueHashes.has(sha256(finding.value))) continue;
283
- const key = `${channel}:${finding.label}:${finding.value}`;
284
- if (seenValues.has(key)) continue;
285
- seenValues.add(key);
286
- findings.push({ label: `${finding.label} in ${channel}`, value: finding.value });
314
+ for (const label of scanTextLabels(text)) {
315
+ const key = `${channel}:${label}`;
316
+ if (seenLabels.has(key)) continue;
317
+ seenLabels.add(key);
318
+ findings.push({ label: `${label} in ${channel}` });
287
319
  }
288
320
  }
289
321
 
@@ -296,12 +328,11 @@ if (bashCommand) {
296
328
  if (allowedValueHashes.has(sha256(finding.value))) continue;
297
329
  findings.push({ label: `${finding.label} in bash command`, value: finding.value });
298
330
  }
299
- for (const finding of scanSecretText(bashCommand)) {
300
- if (allowedValueHashes.has(sha256(finding.value))) continue;
301
- const key = `bash:${finding.label}:${finding.value}`;
302
- if (seenValues.has(key)) continue;
303
- seenValues.add(key);
304
- findings.push({ label: `${finding.label} in bash command`, value: finding.value });
331
+ for (const label of scanTextLabels(bashCommand)) {
332
+ const key = `bash:${label}`;
333
+ if (seenLabels.has(key)) continue;
334
+ seenLabels.add(key);
335
+ findings.push({ label: `${label} in bash command` });
305
336
  }
306
337
  }
307
338
 
@@ -336,10 +367,10 @@ if (event === 'UserPromptSubmit' && promptText && isCompleteMachineEnvelope(prom
336
367
  }
337
368
 
338
369
  // --- block message: redacted previews only, never the secret itself ---
370
+ // The shared scanner returns labels only, so value findings can no longer show
371
+ // a truncated sha256 — the value stays withheld entirely (leak-safe contract).
339
372
  function describeFinding(finding) {
340
- const where = finding.preview
341
- ? `${finding.preview}`
342
- : `${String(finding.value).slice(0, 3)}…(${String(finding.value).length} chars, sha256 ${sha256(String(finding.value)).slice(0, 12)}…)`;
373
+ const where = finding.preview ? `${finding.preview}` : '(value withheld)';
343
374
  return ` - ${finding.label}: ${where}`;
344
375
  }
345
376
 
@@ -363,6 +394,7 @@ process.stdout.write(`${JSON.stringify({
363
394
  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.`,
364
395
  })}\n`);
365
396
  process.exit(2);
397
+ })();
366
398
  NODE
367
399
 
368
400
  exit $?
@@ -226,6 +226,11 @@
226
226
  "SessionStart": [
227
227
  {
228
228
  "hooks": [
229
+ {
230
+ "type": "command",
231
+ "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/project-important.sh\"",
232
+ "timeout": 8
233
+ },
229
234
  {
230
235
  "type": "command",
231
236
  "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/auto-prune-bash.sh\"",