@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/package.json +1 -1
  3. package/scripts/index/refresh-index.mjs +10 -5
  4. package/src/cli/commands/doctor.js +59 -2
  5. package/src/core/gatewayProbe.js +143 -15
  6. package/src/core/gatewayResilienceEnv.js +136 -7
  7. package/src/index/buildIndex.js +74 -24
  8. package/templates/.claude/hooks/auto-allow-bash.sh +24 -1
  9. package/templates/.claude/hooks/auto-prune-bash.sh +4 -0
  10. package/templates/.claude/hooks/block-dangerous.sh +20 -1
  11. package/templates/.claude/hooks/completion-gate.sh +19 -2
  12. package/templates/.claude/hooks/compress-output.sh +17 -2
  13. package/templates/.claude/hooks/context-hardcap-gate.sh +22 -3
  14. package/templates/.claude/hooks/context-window-guard.sh +84 -61
  15. package/templates/.claude/hooks/handoff-model-guard.sh +24 -3
  16. package/templates/.claude/hooks/handoff-resume.sh +21 -3
  17. package/templates/.claude/hooks/post-edit-verify.sh +19 -2
  18. package/templates/.claude/hooks/pre-edit-backup.sh +19 -2
  19. package/templates/.claude/hooks/protect-files.sh +20 -1
  20. package/templates/.claude/hooks/record-execution.sh +20 -2
  21. package/templates/.claude/hooks/reinject-context.sh +1 -1
  22. package/templates/.claude/hooks/reset-compact-pressure.sh +4 -0
  23. package/templates/.claude/hooks/sensitive-data-guard.sh +64 -17
  24. package/templates/.claude/hooks/skill-router.sh +44 -5
  25. package/templates/.claude/hooks/stale-spec-guard.sh +21 -2
  26. package/templates/.claude/hooks/task-watchdog.sh +25 -7
  27. package/templates/.claude/hooks/verification-guard.sh +54 -19
  28. package/templates/.claude/hooks/vision-router.sh +96 -12
  29. package/templates/.claude/ukit/index/lib/index-core.mjs +78 -16
  30. package/templates/.claude/ukit/index/post-edit-verify.mjs +8 -0
  31. package/templates/.claude/ukit/index/pre-edit-backup.mjs +8 -0
  32. package/templates/.claude/ukit/index/refresh-index.mjs +10 -5
  33. package/templates/.claude/ukit/index/stale-spec-check.mjs +8 -0
  34. package/templates/.claude/ukit/runtime/execution-ledger.mjs +8 -0
  35. package/templates/.claude/ukit/runtime/hook-input.mjs +120 -0
  36. package/templates/.claude/ukit/runtime/hook-input.sh +60 -0
  37. package/templates/.claude/ukit/runtime/output-compression.mjs +8 -0
  38. package/templates/.claude/ukit/runtime/reinject-context.mjs +8 -0
  39. 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
- INPUT=$(cat)
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
- INPUT="$INPUT" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE' || true
29
- const fs = require('fs');
30
- const path = require('path');
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 more than this per prompt is not worth the latency. Reading only the TAIL is
33
- // also the safe direction: if the last compact boundary is older than the tail, we count
34
- // the whole tail as live context, which over-estimates and warns earlier.
35
- const MAX_READ_BYTES = 24 * 1024 * 1024;
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(process.env.INPUT || '');
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
- let text;
82
- let truncated;
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
- ({ text, truncated } = readTail(transcriptPath));
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 = lines.length - 1; i >= 0; i -= 1) {
99
- if (!lines[i].includes('compact_boundary')) continue;
100
- try {
101
- const entry = JSON.parse(lines[i]);
102
- if (entry?.type === 'system' && entry?.subtype === 'compact_boundary') {
103
- start = i + 1;
104
- boundaryTs = Date.parse(entry.timestamp) || null;
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
- for (let i = start; i < lines.length; i += 1) {
113
- let entry;
114
- try {
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
- const modelSequence = [];
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
- INPUT=$(cat)
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
- INPUT="$INPUT" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE'
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
- try { return JSON.parse(process.env.INPUT || '{}'); } catch { return {}; }
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
- INPUT=$(cat)
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
- INPUT="$INPUT" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE' || true
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(process.env.INPUT || '');
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
- INPUT=$(cat)
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
- INPUT=$(cat)
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
- INPUT=$(cat)
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
- INPUT=$(cat)
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. Strict by design ("cảnh cực gắt"): mere suspicion
6
- # of private data blocks the call so the USER decides. Detection covers three
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
- INPUT=$(cat)
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
- INPUT="$INPUT" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE'
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(process.env.INPUT || '');
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: /\bAKIA[0-9A-Z]{16}\b/g },
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: /\bAIza[0-9A-Za-z_-]{20,}/g },
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
- const ASSIGNMENT_RE = /(^|[^A-Za-z0-9_])(api[_-]?key|apikey|secret[_-]?key|secret|client[_-]?secret|password|passwd|auth[_-]?token|access[_-]?token|token)\s*[:=]\s*["']?([A-Za-z0-9+/_.~-]{16,})/gi;
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
- 'This gate is intentionally strict — it blocks on suspicion so the USER decides.',
268
- 'To proceed, the USER must choose one of:',
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.',