@ngockhoale/ukit 2.4.0 → 2.4.2
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 +79 -0
- package/package.json +1 -1
- package/scripts/index/refresh-index.mjs +10 -5
- package/src/cli/commands/doctor.js +59 -2
- package/src/core/gatewayProbe.js +143 -15
- package/src/core/gatewayResilienceEnv.js +136 -7
- package/src/index/buildIndex.js +74 -24
- package/templates/.claude/hooks/auto-allow-bash.sh +24 -1
- package/templates/.claude/hooks/auto-prune-bash.sh +4 -0
- package/templates/.claude/hooks/block-dangerous.sh +20 -1
- package/templates/.claude/hooks/completion-gate.sh +19 -2
- package/templates/.claude/hooks/compress-output.sh +17 -2
- package/templates/.claude/hooks/context-hardcap-gate.sh +22 -3
- package/templates/.claude/hooks/context-window-guard.sh +84 -61
- package/templates/.claude/hooks/handoff-model-guard.sh +24 -3
- package/templates/.claude/hooks/handoff-resume.sh +21 -3
- package/templates/.claude/hooks/post-edit-verify.sh +19 -2
- package/templates/.claude/hooks/pre-edit-backup.sh +19 -2
- package/templates/.claude/hooks/protect-files.sh +20 -1
- package/templates/.claude/hooks/record-execution.sh +20 -2
- package/templates/.claude/hooks/reinject-context.sh +1 -1
- package/templates/.claude/hooks/reset-compact-pressure.sh +4 -0
- package/templates/.claude/hooks/sensitive-data-guard.sh +64 -17
- package/templates/.claude/hooks/skill-router.sh +44 -5
- package/templates/.claude/hooks/stale-spec-guard.sh +21 -2
- package/templates/.claude/hooks/task-watchdog.sh +25 -7
- package/templates/.claude/hooks/verification-guard.sh +54 -19
- package/templates/.claude/hooks/vision-router.sh +96 -12
- package/templates/.claude/ukit/index/lib/index-core.mjs +78 -16
- package/templates/.claude/ukit/index/post-edit-verify.mjs +8 -0
- package/templates/.claude/ukit/index/pre-edit-backup.mjs +8 -0
- package/templates/.claude/ukit/index/refresh-index.mjs +10 -5
- package/templates/.claude/ukit/index/stale-spec-check.mjs +8 -0
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +8 -0
- package/templates/.claude/ukit/runtime/hook-input.mjs +120 -0
- package/templates/.claude/ukit/runtime/hook-input.sh +60 -0
- package/templates/.claude/ukit/runtime/output-compression.mjs +8 -0
- package/templates/.claude/ukit/runtime/reinject-context.mjs +8 -0
- package/templates/.claude/ukit/runtime/transcript-tail.mjs +107 -0
|
@@ -22,26 +22,54 @@
|
|
|
22
22
|
# loud early warning is the thing that actually helps: it arrives while compacting or
|
|
23
23
|
# starting a fresh session still works.
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
26
|
+
# shellcheck source=/dev/null
|
|
27
|
+
if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
|
|
28
|
+
trap ukit_cleanup_hook_input EXIT
|
|
29
|
+
# Bounded stdin (H01): stage before anything reads it; Node gets a path, never the payload.
|
|
30
|
+
ukit_stage_hook_input 2097152 truncate || exit 0
|
|
31
|
+
else
|
|
32
|
+
# Runtime helper missing (pre-install tree): bounded inline staging keeps the
|
|
33
|
+
# advisory/fail-open behavior below alive with a capped payload file.
|
|
34
|
+
UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
|
|
35
|
+
head -c 2097153 > "$UKIT_INPUT_FILE" 2>/dev/null
|
|
36
|
+
cat >/dev/null 2>&1
|
|
37
|
+
if [ "$(wc -c < "$UKIT_INPUT_FILE" | tr -d '[:space:]')" -gt 2097152 ]; then
|
|
38
|
+
truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
|
|
39
|
+
fi
|
|
40
|
+
trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
|
|
41
|
+
fi
|
|
26
42
|
PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
27
43
|
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
44
|
+
INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" SCRIPT_DIR="$SCRIPT_DIR" node --input-type=module <<'NODE' || true
|
|
45
|
+
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
|
|
46
|
+
// H04: the transcript scan below is async and tail-bounded. It must be abandoned past
|
|
47
|
+
// the existing 3 s self-deadline: this controller fires a moment BEFORE the hard exit so
|
|
48
|
+
// the in-flight read unwinds cooperatively, and the timer underneath still guarantees the
|
|
49
|
+
// process is gone at the deadline no matter what. Every path exits 0 — advisory only.
|
|
50
|
+
const scanAbort = new AbortController();
|
|
51
|
+
setTimeout(() => { try { scanAbort.abort(); } catch { /* already aborted */ } }, Math.max(0, HOOK_DEADLINE_MS - 250)).unref();
|
|
52
|
+
setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
53
|
+
import fs from 'node:fs';
|
|
54
|
+
import path from 'node:path';
|
|
55
|
+
import { pathToFileURL } from 'node:url';
|
|
31
56
|
|
|
32
|
-
// Reading
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
57
|
+
// Reading only the TAIL is also the safe direction: if the last compact boundary is
|
|
58
|
+
// older than the tail, we count the whole tail as live context, which over-estimates and
|
|
59
|
+
// warns earlier. The byte ceiling itself lives in transcript-tail.mjs
|
|
60
|
+
// (DEFAULT_TAIL_MAX_BYTES) so the reader and its regression tests share one number —
|
|
61
|
+
// never again the old whole-transcript sync stat/read/split of up to 24 MiB per prompt.
|
|
36
62
|
|
|
37
63
|
// Chars-per-token. Calibrated against a real 2843-line transcript: whole-file bytes read
|
|
38
64
|
// ~1.44M "tokens" (constant false alarms), while message content after the last compact
|
|
39
65
|
// boundary read ~145k for a session that did in fact need compacting right after.
|
|
40
66
|
const CHARS_PER_TOKEN = 4;
|
|
41
67
|
|
|
68
|
+
let rawInput = '';
|
|
69
|
+
try { rawInput = fs.readFileSync(process.env.INPUT_FILE || '', 'utf8'); } catch {}
|
|
42
70
|
const payload = (() => {
|
|
43
71
|
try {
|
|
44
|
-
const parsed = JSON.parse(
|
|
72
|
+
const parsed = JSON.parse(rawInput);
|
|
45
73
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
46
74
|
} catch {
|
|
47
75
|
return {};
|
|
@@ -52,23 +80,6 @@ const projectRoot = process.env.PROJECT_ROOT;
|
|
|
52
80
|
const transcriptPath = typeof payload.transcript_path === 'string' ? payload.transcript_path.trim() : '';
|
|
53
81
|
if (!transcriptPath) process.exit(0);
|
|
54
82
|
|
|
55
|
-
function readTail(filePath) {
|
|
56
|
-
const stat = fs.statSync(filePath);
|
|
57
|
-
if (stat.size <= MAX_READ_BYTES) {
|
|
58
|
-
return { text: fs.readFileSync(filePath, 'utf8'), truncated: false };
|
|
59
|
-
}
|
|
60
|
-
const fd = fs.openSync(filePath, 'r');
|
|
61
|
-
try {
|
|
62
|
-
const buf = Buffer.allocUnsafe(MAX_READ_BYTES);
|
|
63
|
-
fs.readSync(fd, buf, 0, MAX_READ_BYTES, stat.size - MAX_READ_BYTES);
|
|
64
|
-
return { text: buf.toString('utf8'), truncated: true };
|
|
65
|
-
} finally {
|
|
66
|
-
fs.closeSync(fd);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// The cap the rest of UKit already agrees on, so one config key tunes both this warning
|
|
71
|
-
// and context-hardcap-gate.sh. Default mirrors compact-threshold.mjs.
|
|
72
83
|
function loadHardCap() {
|
|
73
84
|
try {
|
|
74
85
|
const raw = fs.readFileSync(path.join(projectRoot, '.ukit', 'storage', 'config.json'), 'utf8');
|
|
@@ -78,44 +89,59 @@ function loadHardCap() {
|
|
|
78
89
|
return 500_000;
|
|
79
90
|
}
|
|
80
91
|
|
|
81
|
-
|
|
82
|
-
|
|
92
|
+
// H04: bounded async tail scan instead of a whole-transcript sync stat/read/split. The
|
|
93
|
+
// runtime module owns the byte ceiling (DEFAULT_TAIL_MAX_BYTES, regression-tested well
|
|
94
|
+
// under the old 24 MiB) and the fail-open posture. A missing runtime helper (pre-install
|
|
95
|
+
// tree) simply means nothing to measure: the advisory guard exits 0.
|
|
96
|
+
async function loadTailScanner() {
|
|
97
|
+
try {
|
|
98
|
+
return await import(pathToFileURL(path.join(
|
|
99
|
+
process.env.SCRIPT_DIR || process.cwd(),
|
|
100
|
+
'..',
|
|
101
|
+
'ukit',
|
|
102
|
+
'runtime',
|
|
103
|
+
'transcript-tail.mjs',
|
|
104
|
+
)).href);
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const scanner = await loadTailScanner();
|
|
111
|
+
if (!scanner || typeof scanner.scanTranscriptTail !== 'function') process.exit(0);
|
|
112
|
+
let tail;
|
|
83
113
|
try {
|
|
84
|
-
({
|
|
114
|
+
tail = await scanner.scanTranscriptTail(transcriptPath, { signal: scanAbort.signal });
|
|
85
115
|
} catch {
|
|
86
116
|
// Transcript unreadable (first prompt of a session, permissions, races). Nothing to
|
|
87
117
|
// measure and nothing worth reporting.
|
|
88
118
|
process.exit(0);
|
|
89
119
|
}
|
|
90
|
-
|
|
91
|
-
const lines = text.split('\n').filter(Boolean);
|
|
92
|
-
if (truncated) lines.shift(); // a tail read almost certainly split the first line
|
|
120
|
+
const entries = Array.isArray(tail?.entries) ? tail.entries : [];
|
|
93
121
|
|
|
94
122
|
// Everything before the last compact boundary is already summarised away and is NOT in
|
|
95
123
|
// the live context. Counting it is what makes naive file-size estimates useless.
|
|
96
124
|
let start = 0;
|
|
97
125
|
let boundaryTs = null;
|
|
98
|
-
for (let i =
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
break;
|
|
106
|
-
}
|
|
107
|
-
} catch { /* not a usable boundary line */ }
|
|
126
|
+
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
127
|
+
const entry = entries[i];
|
|
128
|
+
if (entry?.type === 'system' && entry?.subtype === 'compact_boundary') {
|
|
129
|
+
start = i + 1;
|
|
130
|
+
boundaryTs = Date.parse(entry.timestamp) || null;
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
108
133
|
}
|
|
109
134
|
|
|
135
|
+
// If the tail window cuts off history AND holds no boundary, the newest compact boundary
|
|
136
|
+
// may sit before the window: the estimate counts only what was read and is a lower
|
|
137
|
+
// bound. Say so instead of pretending whole-session precision.
|
|
138
|
+
const estimatePartial = Boolean(tail?.truncated) && boundaryTs === null;
|
|
139
|
+
|
|
110
140
|
let contentChars = 0;
|
|
111
141
|
let sidechainEntries = 0;
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
entry = JSON.parse(lines[i]);
|
|
116
|
-
} catch {
|
|
117
|
-
continue;
|
|
118
|
-
}
|
|
142
|
+
const modelSequence = [];
|
|
143
|
+
for (let i = start; i < entries.length; i += 1) {
|
|
144
|
+
const entry = entries[i];
|
|
119
145
|
// Subagent traffic lives in its own context window, not the main one. Count it
|
|
120
146
|
// separately: it is a leading indicator of the "many teammates at once" blow-up, but
|
|
121
147
|
// adding it to the main estimate would overstate the main window.
|
|
@@ -123,6 +149,10 @@ for (let i = start; i < lines.length; i += 1) {
|
|
|
123
149
|
sidechainEntries += 1;
|
|
124
150
|
continue;
|
|
125
151
|
}
|
|
152
|
+
if (entry?.type === 'assistant') {
|
|
153
|
+
const model = entry?.message?.model;
|
|
154
|
+
if (typeof model === 'string' && model.trim()) modelSequence.push(model.trim());
|
|
155
|
+
}
|
|
126
156
|
if (entry?.type !== 'user' && entry?.type !== 'assistant' && entry?.type !== 'attachment') continue;
|
|
127
157
|
try {
|
|
128
158
|
contentChars += JSON.stringify(entry.message ?? entry.attachment ?? '').length;
|
|
@@ -166,18 +196,7 @@ let persistedState = { ...guardState };
|
|
|
166
196
|
// can happen at any context level.
|
|
167
197
|
const RECENT_MODEL_WINDOW = 40;
|
|
168
198
|
const SWAP_NOTE_COOLDOWN_MS = 60 * 60 * 1000;
|
|
169
|
-
|
|
170
|
-
for (let i = start; i < lines.length; i += 1) {
|
|
171
|
-
let entry;
|
|
172
|
-
try {
|
|
173
|
-
entry = JSON.parse(lines[i]);
|
|
174
|
-
} catch {
|
|
175
|
-
continue;
|
|
176
|
-
}
|
|
177
|
-
if (entry?.isSidechain || entry?.type !== 'assistant') continue;
|
|
178
|
-
const model = entry?.message?.model;
|
|
179
|
-
if (typeof model === 'string' && model.trim()) modelSequence.push(model.trim());
|
|
180
|
-
}
|
|
199
|
+
// modelSequence was collected in the single pass over entries above.
|
|
181
200
|
const recentModels = modelSequence.slice(-RECENT_MODEL_WINDOW);
|
|
182
201
|
const currentModel = recentModels[recentModels.length - 1] || null;
|
|
183
202
|
let previousModel = null;
|
|
@@ -273,6 +292,10 @@ if (ratio >= 1) {
|
|
|
273
292
|
lines_out.push(`UKIT CONTEXT WARNING — live context ~${estimatedTokens.toLocaleString()} tokens (${pct}% of the ${hardCap.toLocaleString()} cap).`);
|
|
274
293
|
}
|
|
275
294
|
|
|
295
|
+
if (estimatePartial) {
|
|
296
|
+
lines_out.push(`Note: this estimate is partial — only the last ~${Math.max(1, Math.round(Number(tail?.bytesRead || 0) / 1024))} KiB of transcript were read, so real live context may be higher.`);
|
|
297
|
+
}
|
|
298
|
+
|
|
276
299
|
if (withinCooldown) {
|
|
277
300
|
lines_out.push('(Directive already issued this phase — act on it now if not already done; this is just a reminder.)');
|
|
278
301
|
} else if (run) {
|
|
@@ -11,7 +11,24 @@
|
|
|
11
11
|
# Always hard-blocks (exit 2). No advisory/soft mode — the user explicitly asked for
|
|
12
12
|
# strict enforcement of this contract.
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
15
|
+
# shellcheck source=/dev/null
|
|
16
|
+
if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
|
|
17
|
+
trap ukit_cleanup_hook_input EXIT
|
|
18
|
+
# Bounded stdin (H01): stage before anything reads it; the shell var is capped.
|
|
19
|
+
ukit_stage_hook_input 2097152 truncate || exit 0
|
|
20
|
+
else
|
|
21
|
+
# Runtime helper missing (pre-install tree): bounded inline staging keeps the
|
|
22
|
+
# wrapper's own fail-loud/advisory behavior below alive.
|
|
23
|
+
UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
|
|
24
|
+
head -c 2097153 > "$UKIT_INPUT_FILE" 2>/dev/null
|
|
25
|
+
cat >/dev/null 2>&1
|
|
26
|
+
if [ "$(wc -c < "$UKIT_INPUT_FILE" | tr -d '[:space:]')" -gt 2097152 ]; then
|
|
27
|
+
truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
|
|
28
|
+
fi
|
|
29
|
+
trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
|
|
30
|
+
fi
|
|
31
|
+
INPUT="$(cat "$UKIT_INPUT_FILE")"
|
|
15
32
|
PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
16
33
|
|
|
17
34
|
# Fast path — skip the node spawn (~70ms) when this hook provably has nothing to say.
|
|
@@ -26,12 +43,16 @@ if ! printf '%s' "$INPUT" | grep -qE 'AI_HANDOFF|git[[:space:]]+push'; then
|
|
|
26
43
|
exit 0
|
|
27
44
|
fi
|
|
28
45
|
|
|
29
|
-
|
|
46
|
+
INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE'
|
|
47
|
+
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
|
|
48
|
+
setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
30
49
|
const fs = require('fs');
|
|
31
50
|
const path = require('path');
|
|
32
51
|
|
|
33
52
|
const payload = (() => {
|
|
34
|
-
|
|
53
|
+
let raw = '';
|
|
54
|
+
try { raw = fs.readFileSync(process.env.INPUT_FILE || '', 'utf8'); } catch {}
|
|
55
|
+
try { return JSON.parse(raw || '{}'); } catch { return {}; }
|
|
35
56
|
})();
|
|
36
57
|
const projectRoot = process.env.PROJECT_ROOT;
|
|
37
58
|
const toolName = payload?.tool_name || '';
|
|
@@ -19,10 +19,26 @@
|
|
|
19
19
|
# ADVISORY ONLY — always exit 0. A resume hint must never be able to block a session from
|
|
20
20
|
# starting.
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
23
|
+
# shellcheck source=/dev/null
|
|
24
|
+
if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
|
|
25
|
+
trap ukit_cleanup_hook_input EXIT
|
|
26
|
+
# Bounded stdin (H01): stage before anything reads it; Node gets a path, never the payload.
|
|
27
|
+
ukit_stage_hook_input 2097152 truncate || exit 0
|
|
28
|
+
else
|
|
29
|
+
# Runtime helper missing (pre-install tree): bounded inline staging keeps the
|
|
30
|
+
# advisory/fail-open behavior below alive with a capped payload file.
|
|
31
|
+
UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
|
|
32
|
+
head -c 2097153 > "$UKIT_INPUT_FILE" 2>/dev/null
|
|
33
|
+
cat >/dev/null 2>&1
|
|
34
|
+
if [ "$(wc -c < "$UKIT_INPUT_FILE" | tr -d '[:space:]')" -gt 2097152 ]; then
|
|
35
|
+
truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
|
|
36
|
+
fi
|
|
37
|
+
trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
|
|
38
|
+
fi
|
|
23
39
|
PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
24
40
|
|
|
25
|
-
|
|
41
|
+
INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE' || true
|
|
26
42
|
const fs = require('fs');
|
|
27
43
|
const path = require('path');
|
|
28
44
|
const { pathToFileURL } = require('url');
|
|
@@ -35,9 +51,11 @@ const { pathToFileURL } = require('url');
|
|
|
35
51
|
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
|
|
36
52
|
setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
37
53
|
|
|
54
|
+
let rawInput = '';
|
|
55
|
+
try { rawInput = fs.readFileSync(process.env.INPUT_FILE || '', 'utf8'); } catch {}
|
|
38
56
|
const payload = (() => {
|
|
39
57
|
try {
|
|
40
|
-
const parsed = JSON.parse(
|
|
58
|
+
const parsed = JSON.parse(rawInput);
|
|
41
59
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
42
60
|
} catch {
|
|
43
61
|
return {};
|
|
@@ -2,7 +2,24 @@
|
|
|
2
2
|
# PostToolUse hook: verify risky Edit|Write delta after rollback bytes exist.
|
|
3
3
|
# Matched on: Edit|Write
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
6
|
+
# shellcheck source=/dev/null
|
|
7
|
+
if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
|
|
8
|
+
trap ukit_cleanup_hook_input EXIT
|
|
9
|
+
# Bounded stdin (H01): stage before anything reads it; the shell var is capped.
|
|
10
|
+
ukit_stage_hook_input 2097152 truncate || exit 0
|
|
11
|
+
else
|
|
12
|
+
# Runtime helper missing (pre-install tree): bounded inline staging keeps the
|
|
13
|
+
# wrapper's own fail-loud/advisory behavior below alive.
|
|
14
|
+
UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
|
|
15
|
+
head -c 2097153 > "$UKIT_INPUT_FILE" 2>/dev/null
|
|
16
|
+
cat >/dev/null 2>&1
|
|
17
|
+
if [ "$(wc -c < "$UKIT_INPUT_FILE" | tr -d '[:space:]')" -gt 2097152 ]; then
|
|
18
|
+
truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
|
|
19
|
+
fi
|
|
20
|
+
trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
|
|
21
|
+
fi
|
|
22
|
+
INPUT="$(cat "$UKIT_INPUT_FILE")"
|
|
6
23
|
PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
|
7
24
|
SCRIPT="$PROJECT_ROOT/.claude/ukit/index/post-edit-verify.mjs"
|
|
8
25
|
|
|
@@ -10,4 +27,4 @@ if [ ! -f "$SCRIPT" ]; then
|
|
|
10
27
|
exit 0
|
|
11
28
|
fi
|
|
12
29
|
|
|
13
|
-
printf '%s' "$INPUT" | node "$SCRIPT"
|
|
30
|
+
printf '%s' "$INPUT" | UKIT_HOOK_DEADLINE_MS="${UKIT_HOOK_DEADLINE_MS:-3000}" node "$SCRIPT"
|
|
@@ -2,7 +2,24 @@
|
|
|
2
2
|
# PreToolUse hook: create rollback bytes for risky Edit|Write operations.
|
|
3
3
|
# Matched on: Edit|Write
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
6
|
+
# shellcheck source=/dev/null
|
|
7
|
+
if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
|
|
8
|
+
trap ukit_cleanup_hook_input EXIT
|
|
9
|
+
# Bounded stdin (H01): stage before anything reads it; the shell var is capped.
|
|
10
|
+
ukit_stage_hook_input 2097152 truncate || exit 0
|
|
11
|
+
else
|
|
12
|
+
# Runtime helper missing (pre-install tree): bounded inline staging keeps the
|
|
13
|
+
# wrapper's own fail-loud/advisory behavior below alive.
|
|
14
|
+
UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
|
|
15
|
+
head -c 2097153 > "$UKIT_INPUT_FILE" 2>/dev/null
|
|
16
|
+
cat >/dev/null 2>&1
|
|
17
|
+
if [ "$(wc -c < "$UKIT_INPUT_FILE" | tr -d '[:space:]')" -gt 2097152 ]; then
|
|
18
|
+
truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
|
|
19
|
+
fi
|
|
20
|
+
trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
|
|
21
|
+
fi
|
|
22
|
+
INPUT="$(cat "$UKIT_INPUT_FILE")"
|
|
6
23
|
PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
|
7
24
|
SCRIPT="$PROJECT_ROOT/.claude/ukit/index/pre-edit-backup.mjs"
|
|
8
25
|
|
|
@@ -10,4 +27,4 @@ if [ ! -f "$SCRIPT" ]; then
|
|
|
10
27
|
exit 0
|
|
11
28
|
fi
|
|
12
29
|
|
|
13
|
-
printf '%s' "$INPUT" | node "$SCRIPT"
|
|
30
|
+
printf '%s' "$INPUT" | UKIT_HOOK_DEADLINE_MS="${UKIT_HOOK_DEADLINE_MS:-3000}" node "$SCRIPT"
|
|
@@ -2,13 +2,32 @@
|
|
|
2
2
|
# PreToolUse hook: Block edits to sensitive/protected files
|
|
3
3
|
# Matched on: Edit|Write
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
6
|
+
# shellcheck source=/dev/null
|
|
7
|
+
if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
|
|
8
|
+
trap ukit_cleanup_hook_input EXIT
|
|
9
|
+
# Bounded stdin (H01): stage before anything reads it; the shell var is capped.
|
|
10
|
+
ukit_stage_hook_input 2097152 truncate || exit 0
|
|
11
|
+
else
|
|
12
|
+
# Runtime helper missing (pre-install tree): bounded inline staging keeps the
|
|
13
|
+
# wrapper's own fail-loud/advisory behavior below alive.
|
|
14
|
+
UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
|
|
15
|
+
head -c 2097153 > "$UKIT_INPUT_FILE" 2>/dev/null
|
|
16
|
+
cat >/dev/null 2>&1
|
|
17
|
+
if [ "$(wc -c < "$UKIT_INPUT_FILE" | tr -d '[:space:]')" -gt 2097152 ]; then
|
|
18
|
+
truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
|
|
19
|
+
fi
|
|
20
|
+
trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
|
|
21
|
+
fi
|
|
22
|
+
INPUT="$(cat "$UKIT_INPUT_FILE")"
|
|
6
23
|
# jq is absent on stock macOS. Keep jq as the low-latency normal path, but fall back to
|
|
7
24
|
# UKit's required Node runtime so a missing optional binary cannot disable this gate.
|
|
8
25
|
if command -v jq >/dev/null 2>&1 && jq --version >/dev/null 2>&1; then
|
|
9
26
|
FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty')
|
|
10
27
|
else
|
|
11
28
|
FILE_PATH=$(printf '%s' "$INPUT" | node -e '
|
|
29
|
+
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 3000;
|
|
30
|
+
setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
12
31
|
const chunks = [];
|
|
13
32
|
process.stdin.on("data", (chunk) => chunks.push(chunk));
|
|
14
33
|
process.stdin.on("end", () => {
|
|
@@ -2,7 +2,25 @@
|
|
|
2
2
|
# PostToolUse hook: persist session-scoped source/write/verification receipts.
|
|
3
3
|
# ADVISORY ONLY — always exit 0. A missing or failing runtime must be loud, not a silent pass.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
6
|
+
# shellcheck source=/dev/null
|
|
7
|
+
if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
|
|
8
|
+
trap ukit_cleanup_hook_input EXIT
|
|
9
|
+
# Bounded stdin (H01): receipts can legitimately be large, so the staged file
|
|
10
|
+
# is the transport and the shell var only mirrors the capped payload.
|
|
11
|
+
ukit_stage_hook_input 33554432 temp-file || exit 0
|
|
12
|
+
else
|
|
13
|
+
# Runtime helper missing (pre-install tree): bounded inline staging keeps the
|
|
14
|
+
# wrapper's own fail-loud/advisory behavior below alive.
|
|
15
|
+
UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
|
|
16
|
+
head -c 33554433 > "$UKIT_INPUT_FILE" 2>/dev/null
|
|
17
|
+
cat >/dev/null 2>&1
|
|
18
|
+
if [ "$(wc -c < "$UKIT_INPUT_FILE" | tr -d '[:space:]')" -gt 33554432 ]; then
|
|
19
|
+
truncate -s 33554432 "$UKIT_INPUT_FILE" 2>/dev/null
|
|
20
|
+
fi
|
|
21
|
+
trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
|
|
22
|
+
fi
|
|
23
|
+
INPUT="$(cat "$UKIT_INPUT_FILE")"
|
|
6
24
|
PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
7
25
|
SCRIPT="$PROJECT_ROOT/.claude/ukit/runtime/execution-ledger.mjs"
|
|
8
26
|
|
|
@@ -11,7 +29,7 @@ if [ ! -f "$SCRIPT" ]; then
|
|
|
11
29
|
exit 0
|
|
12
30
|
fi
|
|
13
31
|
|
|
14
|
-
OUTPUT=$(printf '%s' "$INPUT" | UKIT_HARNESS=claude-code node "$SCRIPT" --record)
|
|
32
|
+
OUTPUT=$(printf '%s' "$INPUT" | UKIT_HARNESS=claude-code UKIT_HOOK_DEADLINE_MS="${UKIT_HOOK_DEADLINE_MS:-3000}" node "$SCRIPT" --record)
|
|
15
33
|
STATUS=$?
|
|
16
34
|
|
|
17
35
|
if [ "$STATUS" -ne 0 ]; then
|
|
@@ -10,7 +10,7 @@ HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
|
10
10
|
SCRIPT_PATH="$HOOK_DIR/../ukit/runtime/reinject-context.mjs"
|
|
11
11
|
|
|
12
12
|
if [ -f "$SCRIPT_PATH" ]; then
|
|
13
|
-
node "$SCRIPT_PATH"
|
|
13
|
+
UKIT_HOOK_DEADLINE_MS="${UKIT_HOOK_DEADLINE_MS:-3000}" node "$SCRIPT_PATH"
|
|
14
14
|
exit $?
|
|
15
15
|
fi
|
|
16
16
|
|
|
@@ -26,6 +26,10 @@ fi
|
|
|
26
26
|
|
|
27
27
|
# The lock directory lives beside the state file; its parent exists because the file does.
|
|
28
28
|
node -e '
|
|
29
|
+
// Deadline must exceed the bounded lock wait in this block (maxWaitMs = 5000) so the
|
|
30
|
+
// watchdog can never fire while the mutation is still legally waiting for the lock.
|
|
31
|
+
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 8000;
|
|
32
|
+
setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
29
33
|
const crypto = require("crypto");
|
|
30
34
|
const fs = require("fs");
|
|
31
35
|
const path = require("path");
|
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
# PreToolUse (Read|Grep|Bash) + UserPromptSubmit hook: sensitive-data gate.
|
|
3
3
|
#
|
|
4
4
|
# Blocks sensitive data — API keys, private keys, credentials, secret files —
|
|
5
|
-
# from being sent to the AI.
|
|
6
|
-
#
|
|
5
|
+
# from being sent to the AI. Detection is format-true only (owner directive
|
|
6
|
+
# "chặn khi thấy rõ" after repeated false positives): a text match must itself
|
|
7
|
+
# BE a credential — a vendor-formatted key, a private key block, or a JWT.
|
|
8
|
+
# Keyword-assignment guessing and loose bearer matching are gone on purpose;
|
|
9
|
+
# code, config snippets, hashes and encoded blobs flow. Detection covers three
|
|
7
10
|
# egress channels into model context:
|
|
8
11
|
# 1. Read/Grep of secret files (.env*, *.pem, id_rsa, credentials.json, ...)
|
|
9
12
|
# 2. Bash commands that dump secrets (cat .env, bare `env`, curl -u user:pass,
|
|
@@ -22,17 +25,43 @@
|
|
|
22
25
|
# Fails open only when there is nothing to scan (malformed stdin, no channel):
|
|
23
26
|
# a broken gate must not brick every session, but every real detection blocks.
|
|
24
27
|
|
|
25
|
-
|
|
28
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
26
29
|
PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
30
|
+
# Bounded stdin (H01) — fail-closed overflow: an oversized payload is REFUSED
|
|
31
|
+
# (silent exit 2, nothing staged, nothing echoed). The gate never truncates
|
|
32
|
+
# unknown overflow and never materializes it in a shell variable.
|
|
33
|
+
# shellcheck source=/dev/null
|
|
34
|
+
if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
|
|
35
|
+
trap ukit_cleanup_hook_input EXIT
|
|
36
|
+
ukit_stage_hook_input 2097152 refuse
|
|
37
|
+
STAGE_RC=$?
|
|
38
|
+
if [ "$STAGE_RC" -ne 0 ]; then
|
|
39
|
+
exit "$STAGE_RC"
|
|
40
|
+
fi
|
|
41
|
+
else
|
|
42
|
+
# Runtime helper missing (pre-install tree): same refuse posture, inline.
|
|
43
|
+
UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")" || exit 2
|
|
44
|
+
head -c 2097153 > "$UKIT_INPUT_FILE" 2>/dev/null
|
|
45
|
+
cat >/dev/null 2>&1
|
|
46
|
+
if [ "$(wc -c < "$UKIT_INPUT_FILE" | tr -d '[:space:]')" -gt 2097152 ]; then
|
|
47
|
+
rm -f "$UKIT_INPUT_FILE"
|
|
48
|
+
exit 2
|
|
49
|
+
fi
|
|
50
|
+
trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
|
|
51
|
+
fi
|
|
27
52
|
|
|
28
|
-
|
|
53
|
+
INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE'
|
|
54
|
+
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
|
|
55
|
+
setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
29
56
|
const fs = require('fs');
|
|
30
57
|
const path = require('path');
|
|
31
58
|
const { createHash } = require('crypto');
|
|
32
59
|
|
|
60
|
+
let rawInput = '';
|
|
61
|
+
try { rawInput = fs.readFileSync(process.env.INPUT_FILE || '', 'utf8'); } catch {}
|
|
33
62
|
const payload = (() => {
|
|
34
63
|
try {
|
|
35
|
-
const parsed = JSON.parse(
|
|
64
|
+
const parsed = JSON.parse(rawInput);
|
|
36
65
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
37
66
|
} catch {
|
|
38
67
|
return {};
|
|
@@ -76,22 +105,26 @@ function pathAllowed(filePath) {
|
|
|
76
105
|
}
|
|
77
106
|
|
|
78
107
|
// --- high-confidence secret value patterns (shared by prompt + bash scanning) ---
|
|
108
|
+
// AKIA/ASIA/AIza prefixes are pure base64-compatible text, so an encoded blob
|
|
109
|
+
// can contain a coincidental key-shaped substring (round-17 live case: ASIA…
|
|
110
|
+
// inside a base64 payload). Those rules must stand alone — no base64/base64url
|
|
111
|
+
// character on either side. Padding "=" is a boundary: it only ends a blob.
|
|
79
112
|
const TOKEN_PATTERNS = [
|
|
80
113
|
{ label: 'OpenAI/Anthropic-style API key', re: /\bsk-(?:proj-|ant-|svc-|acct-|admin-)?[A-Za-z0-9_-]{20,}/g },
|
|
81
|
-
{ label: 'AWS access key id', re:
|
|
114
|
+
{ label: 'AWS access key id', re: /(?<![A-Za-z0-9+/_-])(?:AKIA|ASIA)[0-9A-Z]{16}(?![A-Za-z0-9+/_-])/g },
|
|
82
115
|
{ label: 'GitHub token', re: /\bgh[pousr]_[A-Za-z0-9]{30,}\b/g },
|
|
83
116
|
{ label: 'GitHub fine-grained token', re: /\bgithub_pat_[A-Za-z0-9_]{20,}/g },
|
|
84
117
|
{ label: 'GitLab token', re: /\bglpat-[A-Za-z0-9_-]{20,}/g },
|
|
85
118
|
{ label: 'Slack token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}/g },
|
|
86
|
-
{ label: 'Google API key', re:
|
|
119
|
+
{ label: 'Google API key', re: /(?<![A-Za-z0-9+/_-])AIza[0-9A-Za-z_-]{20,}(?![A-Za-z0-9+/_-])/g },
|
|
87
120
|
{ label: 'Stripe live key', re: /\b[srp]k_live_[A-Za-z0-9]{20,}/g },
|
|
88
121
|
{ label: 'JWT', re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g },
|
|
89
122
|
{ label: 'private key block', re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----/g },
|
|
90
|
-
{ label: 'bearer token', re: /\bBearer\s+[A-Za-z0-9._+/=-]{20,}/gi },
|
|
91
123
|
];
|
|
92
124
|
|
|
93
|
-
|
|
94
|
-
|
|
125
|
+
// No keyword-assignment lane ("password: …", "token = …") on purpose — it was
|
|
126
|
+
// the source of every false positive: it guessed at whatever followed a
|
|
127
|
+
// keyword, blocking code, prose and shell syntax that is never a secret.
|
|
95
128
|
function scanSecretText(text) {
|
|
96
129
|
const found = [];
|
|
97
130
|
for (const { label, re } of TOKEN_PATTERNS) {
|
|
@@ -101,11 +134,6 @@ function scanSecretText(text) {
|
|
|
101
134
|
found.push({ label, value: match[0] });
|
|
102
135
|
}
|
|
103
136
|
}
|
|
104
|
-
ASSIGNMENT_RE.lastIndex = 0;
|
|
105
|
-
let match;
|
|
106
|
-
while ((match = ASSIGNMENT_RE.exec(text)) !== null) {
|
|
107
|
-
found.push({ label: `${match[2].toLowerCase()} assignment`, value: match[3] });
|
|
108
|
-
}
|
|
109
137
|
return found;
|
|
110
138
|
}
|
|
111
139
|
|
|
@@ -253,6 +281,24 @@ if (findings.length === 0) {
|
|
|
253
281
|
process.exit(0);
|
|
254
282
|
}
|
|
255
283
|
|
|
284
|
+
// --- machine-generated envelope lane (UserPromptSubmit only) ---
|
|
285
|
+
// Harness plumbing (<task-notification>, <system-reminder>,
|
|
286
|
+
// <cross-session-message>) legitimately carries fixture/log content through
|
|
287
|
+
// the prompt channel — test tokens quoted in task bodies, CI logs, agent
|
|
288
|
+
// results. Blocking it silently drops the notification and stalls pipelines
|
|
289
|
+
// ("Waiting for N background agents"). These envelopes flow with a redacted
|
|
290
|
+
// advisory: labels + counts only, never values. Everything a human types —
|
|
291
|
+
// and every other event/channel — stays fail-closed below.
|
|
292
|
+
const MACHINE_ENVELOPE_RE = /^\s*<(?:task-notification|system-reminder|cross-session-message)\b/i;
|
|
293
|
+
const promptText = textChannels.length > 0 ? textChannels[0].text : null;
|
|
294
|
+
if (event === 'UserPromptSubmit' && promptText && MACHINE_ENVELOPE_RE.test(promptText)) {
|
|
295
|
+
const labels = [...new Set(findings.map((f) => f.label))];
|
|
296
|
+
process.stdout.write(`${JSON.stringify({
|
|
297
|
+
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.`,
|
|
298
|
+
})}\n`);
|
|
299
|
+
process.exit(0);
|
|
300
|
+
}
|
|
301
|
+
|
|
256
302
|
// --- block message: redacted previews only, never the secret itself ---
|
|
257
303
|
function describeFinding(finding) {
|
|
258
304
|
const where = finding.preview
|
|
@@ -264,8 +310,9 @@ function describeFinding(finding) {
|
|
|
264
310
|
const lines = [
|
|
265
311
|
`BLOCKED (sensitive data): ${findings.length} potential secret(s) detected. Nothing was sent to the AI.`,
|
|
266
312
|
...findings.map(describeFinding),
|
|
267
|
-
'
|
|
268
|
-
'
|
|
313
|
+
'Detection is format-true only: a match must itself be a vendor-formatted key,',
|
|
314
|
+
'a private key block, a JWT, or a credentials file. Code, prose, hashes and',
|
|
315
|
+
'encoded blobs are not blocked. To proceed, the USER can choose one of:',
|
|
269
316
|
' 1. Redact the secret (placeholder like <API_KEY>) and retry.',
|
|
270
317
|
' 2. Approve it explicitly: add the full sha256 (shasum -a 256 of the value) or the file path',
|
|
271
318
|
' to .ukit/storage/security/allowlist.json — that file is protected from AI edits.',
|