@ngockhoale/ukit 2.4.3 → 2.5.0
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 +57 -0
- package/README.md +20 -0
- package/manifests/platform.full.yaml +32 -1
- package/package.json +1 -1
- package/src/cli/commands/doctor.js +132 -2
- package/src/cli/commands/uninstall.js +18 -0
- package/src/core/applyPlan.js +17 -2
- package/src/core/diffPlan.js +35 -0
- package/src/core/fileOps.js +26 -0
- package/src/core/projectImportant.js +430 -0
- package/src/core/sensitiveValueScanner.js +118 -0
- package/src/core/status.js +55 -1
- package/src/core/uninstall.js +183 -3
- package/templates/.claude/hooks/project-important.sh +67 -0
- package/templates/.claude/hooks/sensitive-data-guard.sh +80 -48
- package/templates/.claude/settings.json +5 -0
- package/templates/.claude/ukit/runtime/project-important.mjs +381 -0
- package/templates/.claude/ukit/runtime/sensitive-value-scanner.mjs +128 -0
- package/templates/.omp/hooks/pre/ukit-bridge.js +7 -4
- package/templates/AGENTS.md +8 -0
- package/templates/PROJECT_IMPORTANT.md +9 -0
package/src/core/uninstall.js
CHANGED
|
@@ -2,12 +2,154 @@ 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
|
+
return plan;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Real-run backup: byte-identical raw copy via exclusive create (COPYFILE_EXCL —
|
|
96
|
+
// never overwrites). EEXIST means the slot filled between plan and copy; the walk
|
|
97
|
+
// re-checks it (equal bytes → reuse, else next suffix). Any other failure cleans
|
|
98
|
+
// up a possible partial copy and ABORTS uninstall before managed deletion.
|
|
99
|
+
async function createImportantBackup(projectRoot, sourcePath, sourceBytes) {
|
|
100
|
+
for (let index = 0; index <= IMPORTANT_BACKUP_COLLISION_LIMIT; index += 1) {
|
|
101
|
+
const candidatePath = path.join(projectRoot, importantBackupCandidateName(index));
|
|
102
|
+
let candidateStat;
|
|
103
|
+
try {
|
|
104
|
+
candidateStat = await fs.lstat(candidatePath);
|
|
105
|
+
} catch {
|
|
106
|
+
candidateStat = null;
|
|
107
|
+
}
|
|
108
|
+
if (candidateStat) {
|
|
109
|
+
if (candidateStat.isFile() && !candidateStat.isSymbolicLink()) {
|
|
110
|
+
const existingBytes = await fs.readFile(candidatePath).catch(() => null);
|
|
111
|
+
if (existingBytes && existingBytes.equals(sourceBytes)) {
|
|
112
|
+
return candidatePath; // reused — already byte-identical
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
await copyFileRawExclusive(sourcePath, candidatePath); // preserves mode where supported
|
|
119
|
+
return candidatePath;
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (error?.code === 'EEXIST') {
|
|
122
|
+
// Lost the race — the slot now exists. Reuse it when its bytes match the
|
|
123
|
+
// source; otherwise advance to the next suffix.
|
|
124
|
+
try {
|
|
125
|
+
const racedStat = await fs.lstat(candidatePath);
|
|
126
|
+
if (racedStat.isFile() && !racedStat.isSymbolicLink()) {
|
|
127
|
+
const racedBytes = await fs.readFile(candidatePath).catch(() => null);
|
|
128
|
+
if (racedBytes && racedBytes.equals(sourceBytes)) {
|
|
129
|
+
return candidatePath;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
// vanished again — either way, move on
|
|
134
|
+
}
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
await fs.rm(candidatePath, { force: true }); // clean partial backup
|
|
139
|
+
} catch {
|
|
140
|
+
// best-effort cleanup only
|
|
141
|
+
}
|
|
142
|
+
// Error carries no source content — only the failing operation.
|
|
143
|
+
throw new Error(
|
|
144
|
+
`[UKit] Uninstall aborted: could not back up ${PROJECT_IMPORTANT_FILENAME} (${error?.code || error?.message}). Managed paths were not removed.`,
|
|
145
|
+
{ cause: error },
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
throw new Error(
|
|
150
|
+
`[UKit] Uninstall aborted: no free backup name for ${PROJECT_IMPORTANT_FILENAME} after ${IMPORTANT_BACKUP_COLLISION_LIMIT} collisions. Managed paths were not removed.`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
11
153
|
|
|
12
154
|
// Use lstat (not access) so broken symlinks are also detected as existing.
|
|
13
155
|
async function pathExistsLstat(targetPath) {
|
|
@@ -254,13 +396,42 @@ export async function uninstallUkit({ projectRoot, dryRun = false }) {
|
|
|
254
396
|
);
|
|
255
397
|
}
|
|
256
398
|
|
|
399
|
+
// PROJECT_IMPORTANT.md preservation — read-only plan first (spec §15). The
|
|
400
|
+
// source is never touched: only inspected via lstat + readFile.
|
|
401
|
+
const importantPlan = await planImportantBackup(projectRoot);
|
|
402
|
+
|
|
257
403
|
if (dryRun) {
|
|
258
|
-
//
|
|
404
|
+
// Dry run creates nothing: report the collision-resolved candidate and the
|
|
405
|
+
// preserved/refused state only. lstat (not access) keeps broken symlinks visible.
|
|
259
406
|
const wouldRemove = [];
|
|
260
407
|
for (const { abs } of safeEntries) {
|
|
261
408
|
if (await pathExistsLstat(abs)) wouldRemove.push(abs);
|
|
262
409
|
}
|
|
263
|
-
return {
|
|
410
|
+
return {
|
|
411
|
+
removed: 0,
|
|
412
|
+
attempted: allEntries.length,
|
|
413
|
+
wasInstalled: true,
|
|
414
|
+
wouldRemove,
|
|
415
|
+
wouldBackup: importantPlan.wouldBackup,
|
|
416
|
+
backupPaths: importantPlan.backupPath ? [importantPlan.backupPath] : [],
|
|
417
|
+
preservedPaths: importantPlan.preservedPaths,
|
|
418
|
+
backupWarnings: importantPlan.backupWarnings,
|
|
419
|
+
skippedSymlinkParents,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Backup runs BEFORE any destructive step. A failure here aborts uninstall —
|
|
424
|
+
// managed paths stay intact and the source is never modified.
|
|
425
|
+
const backupPaths = [];
|
|
426
|
+
if (importantPlan.wouldBackup) {
|
|
427
|
+
const backupPath = await createImportantBackup(
|
|
428
|
+
projectRoot,
|
|
429
|
+
importantPlan.sourcePath,
|
|
430
|
+
importantPlan.sourceBytes,
|
|
431
|
+
);
|
|
432
|
+
backupPaths.push(backupPath);
|
|
433
|
+
} else if (importantPlan.backupPath && importantPlan.reused) {
|
|
434
|
+
backupPaths.push(importantPlan.backupPath); // existing byte-identical backup
|
|
264
435
|
}
|
|
265
436
|
|
|
266
437
|
// Remove all paths in parallel
|
|
@@ -289,5 +460,14 @@ export async function uninstallUkit({ projectRoot, dryRun = false }) {
|
|
|
289
460
|
await cleanupEmptyParents(removedPath, projectRoot);
|
|
290
461
|
}
|
|
291
462
|
|
|
292
|
-
return {
|
|
463
|
+
return {
|
|
464
|
+
removed,
|
|
465
|
+
attempted: allEntries.length,
|
|
466
|
+
wasInstalled: true,
|
|
467
|
+
wouldBackup: importantPlan.wouldBackup,
|
|
468
|
+
backupPaths,
|
|
469
|
+
preservedPaths: importantPlan.preservedPaths,
|
|
470
|
+
backupWarnings: importantPlan.backupWarnings,
|
|
471
|
+
skippedSymlinkParents,
|
|
472
|
+
};
|
|
293
473
|
}
|
|
@@ -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
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
{
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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\"",
|