@ngockhoale/ukit 2.4.1 → 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 +65 -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 +33 -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
@@ -4,14 +4,24 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
 
6
6
  // Managed defaults that keep Claude Code responsive when it is pointed at a custom
7
- // gateway (any non-empty ANTHROPIC_BASE_URL). `CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK='1'`
8
- // forces streaming mode so the client never falls back to a non-streaming POST that can
9
- // stall for minutes waiting for the full response. `CLAUDE_STREAM_IDLE_TIMEOUT_MS='600000'`
10
- // raises Claude Code's internal idle timeout from its default (the gateway-side keep-alive
11
- // can sit idle longer than the default when the upstream is slow).
7
+ // gateway (any non-empty ANTHROPIC_BASE_URL). Posture since 2.4.2 — "fail fast, recover
8
+ // via non-streaming" — based on 2026-09-16 live probes of the stall-class gateway:
9
+ // * The gateway buffers entire streams (23 SSE events delivered in ONE body read), so a
10
+ // long generation is silent until it finishes and Claude Code's stream-idle watchdog
11
+ // is the only thing that can end the silence. `CLAUDE_STREAM_IDLE_TIMEOUT_MS='120000'`
12
+ // bounds that dead air to 2 minutes instead of hanging for 10.
13
+ // * The non-streaming route is HEALTHY on the stall-class gateway (HTTP 200 + valid
14
+ // Anthropic Message), and a non-streaming POST has no idle watchdog at all — it is the
15
+ // one request shape that reliably survives a buffered long generation. So the fallback
16
+ // must stay ENABLED (`CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK='0'`); disabling it
17
+ // (the 2.3.16–2.4.1 posture) removed the only recovery path and turned stalls into
18
+ // silent freezes the user had to interrupt by hand.
12
19
  //
13
20
  // These are written ONLY when no user-set value already lives in the project's
14
- // `.claude/settings.json` env block — never clobber, always preserve. Re-running
21
+ // `.claude/settings.json` env block — never clobber, always preserve — EXCEPT for values
22
+ // equal to `GATEWAY_RESILIENCE_ENV_LEGACY_DEFAULTS` (the shipped defaults of
23
+ // 2.3.16–2.4.1): those were written by UKit itself, so they migrate to the current
24
+ // defaults on the next install instead of being mistaken for user intent. Re-running
15
25
  // `ukit install` is a no-op once the keys are in place.
16
26
  //
17
27
  // Probe order mirrors `templates/.claude/ukit/index/unic-gateway.mjs` (env → project
@@ -21,12 +31,22 @@ import path from 'node:path';
21
31
  // is pointed at ANY gateway, so we apply resilience regardless of vendor.
22
32
 
23
33
  export const GATEWAY_RESILIENCE_ENV_DEFAULTS = Object.freeze({
34
+ CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK: '0',
35
+ CLAUDE_STREAM_IDLE_TIMEOUT_MS: '120000',
36
+ });
37
+
38
+ // Values shipped as the managed defaults by 2.3.16–2.4.1. A settings env key holding one
39
+ // of these exact values was written by UKit, not by the user, so `applyGatewayResilienceEnv`
40
+ // migrates it to the current default. A user who deliberately tuned a value always uses a
41
+ // non-shipped number (e.g. '300000', '900000') and stays untouched.
42
+ export const GATEWAY_RESILIENCE_ENV_LEGACY_DEFAULTS = Object.freeze({
24
43
  CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK: '1',
25
44
  CLAUDE_STREAM_IDLE_TIMEOUT_MS: '600000',
26
45
  });
27
46
 
28
47
  const BASE_URL_ENV_VAR = 'ANTHROPIC_BASE_URL';
29
48
  const SETTINGS_RELATIVE_PATH = path.join('.claude', 'settings.json');
49
+ const PROFILE_RELATIVE_PATH = path.join('.claude', 'profile');
30
50
 
31
51
  function readBaseUrlFromEnvValue(value) {
32
52
  return typeof value === 'string' && value.trim().length > 0 ? value : null;
@@ -124,6 +144,7 @@ function backupPathFor(settingsPath) {
124
144
  * applied: string[],
125
145
  * unchanged: string[],
126
146
  * skipped: string[],
147
+ * migrated: string[],
127
148
  * changed: boolean,
128
149
  * reason?: string,
129
150
  * }>}
@@ -162,6 +183,7 @@ export async function applyGatewayResilienceEnv({
162
183
  applied: [],
163
184
  unchanged: [],
164
185
  skipped: [],
186
+ migrated: [],
165
187
  changed: false,
166
188
  reason: `failed to read settings: ${error?.message ?? String(error)}`,
167
189
  };
@@ -180,6 +202,7 @@ export async function applyGatewayResilienceEnv({
180
202
  applied: [],
181
203
  unchanged: [],
182
204
  skipped: [],
205
+ migrated: [],
183
206
  changed: false,
184
207
  reason: `failed to parse settings JSON: ${error?.message ?? String(error)}`,
185
208
  };
@@ -204,6 +227,7 @@ export async function applyGatewayResilienceEnv({
204
227
  const applied = [];
205
228
  const unchanged = [];
206
229
  const skipped = [];
230
+ const migrated = [];
207
231
  let mutated = false;
208
232
 
209
233
  for (const [key, defaultValue] of Object.entries(GATEWAY_RESILIENCE_ENV_DEFAULTS)) {
@@ -218,6 +242,18 @@ export async function applyGatewayResilienceEnv({
218
242
  unchanged.push(key);
219
243
  continue;
220
244
  }
245
+ // A value equal to a previously-shipped UKit default was written by UKit itself —
246
+ // migrate it to the current default instead of treating it as user intent.
247
+ if (
248
+ typeof currentValue === 'string' &&
249
+ currentValue === GATEWAY_RESILIENCE_ENV_LEGACY_DEFAULTS[key]
250
+ ) {
251
+ settings.env[key] = defaultValue;
252
+ applied.push(key);
253
+ migrated.push(key);
254
+ mutated = true;
255
+ continue;
256
+ }
221
257
  // User owns a non-default value — never touch it.
222
258
  skipped.push(key);
223
259
  }
@@ -228,6 +264,7 @@ export async function applyGatewayResilienceEnv({
228
264
  applied,
229
265
  unchanged,
230
266
  skipped,
267
+ migrated,
231
268
  changed: false,
232
269
  };
233
270
  }
@@ -243,6 +280,7 @@ export async function applyGatewayResilienceEnv({
243
280
  applied,
244
281
  unchanged,
245
282
  skipped,
283
+ migrated,
246
284
  changed: true,
247
285
  };
248
286
  }
@@ -271,7 +309,11 @@ export function formatGatewayResilienceReport(report) {
271
309
  );
272
310
 
273
311
  for (const key of report.applied || []) {
274
- lines.push(` - ${key} = ${JSON.stringify(GATEWAY_RESILIENCE_ENV_DEFAULTS[key])} (applied)`);
312
+ const legacyValue = GATEWAY_RESILIENCE_ENV_LEGACY_DEFAULTS[key];
313
+ const migrated = (report.migrated || []).includes(key) && legacyValue !== undefined;
314
+ lines.push(
315
+ ` - ${key} = ${JSON.stringify(GATEWAY_RESILIENCE_ENV_DEFAULTS[key])}${migrated ? ` (migrated from previous UKit default ${JSON.stringify(legacyValue)})` : ' (applied)'}`,
316
+ );
275
317
  }
276
318
  for (const key of report.unchanged || []) {
277
319
  lines.push(` - ${key} = ${JSON.stringify(GATEWAY_RESILIENCE_ENV_DEFAULTS[key])} (already set)`);
@@ -290,3 +332,90 @@ export function formatGatewayResilienceReport(report) {
290
332
 
291
333
  return lines;
292
334
  }
335
+
336
+ /**
337
+ * Advisory presence scan of the per-profile settings files under
338
+ * `<homeDir>/.claude/profile/`. A session launched with
339
+ * `--settings ~/.claude/profile/<name>.json` bypasses `~/.claude/settings.json` entirely, so
340
+ * a managed resilience key can be absent exactly where it matters while every other check
341
+ * reports green. This is the delivery-gap detector for that hole.
342
+ *
343
+ * Presence-check only — never a value-check. After the documented posture flip the values
344
+ * are user/machine-owned (a profile may legitimately hold a legacy `'1'`/`'600000'` pair or a
345
+ * tuned number), so "is the key present" is the whole contract and no value is compared
346
+ * against `GATEWAY_RESILIENCE_ENV_DEFAULTS`.
347
+ *
348
+ * Only regular files ending in `.json` are scanned (sorted by name); decoys that live in the
349
+ * real profile dir — backups like `x.json.bak-20260916`, `notes.sh`, and subdirectories — are
350
+ * ignored, and the scan is not recursive. An absent profile directory is not an error: it
351
+ * resolves `{ scanned: [], compliant: true }` so a machine that never used profiles stays
352
+ * quiet. Never throws: an unreadable directory, unreadable file, or invalid JSON is
353
+ * reflected in the per-file result instead of propagating.
354
+ *
355
+ * @param {{ homeDir?: string, profileDir?: string|null, keys?: string[]|null }} [options]
356
+ * @returns {Promise<{ profileDir: string, scanned: Array<{ file: string, validJson: boolean, hasEnvBlock: boolean, missing: string[] }>, compliant: boolean }>}
357
+ */
358
+ export async function scanProfileResilienceEnv({
359
+ homeDir = os.homedir(),
360
+ profileDir = null,
361
+ keys = null,
362
+ } = {}) {
363
+ const resolvedProfileDir = profileDir || path.join(homeDir, PROFILE_RELATIVE_PATH);
364
+ const managedKeys = Array.isArray(keys) && keys.length > 0
365
+ ? keys
366
+ : Object.keys(GATEWAY_RESILIENCE_ENV_DEFAULTS);
367
+
368
+ const emptyResult = { profileDir: resolvedProfileDir, scanned: [], compliant: true };
369
+
370
+ let entries;
371
+ try {
372
+ entries = await fs.readdir(resolvedProfileDir, { withFileTypes: true });
373
+ } catch {
374
+ // Absent (ENOENT) or unreadable (EACCES) profile dir — nothing to warn about.
375
+ return emptyResult;
376
+ }
377
+
378
+ const jsonFiles = entries
379
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
380
+ .map((entry) => entry.name)
381
+ .sort();
382
+
383
+ if (jsonFiles.length === 0) {
384
+ return emptyResult;
385
+ }
386
+
387
+ const scanned = [];
388
+ for (const name of jsonFiles) {
389
+ const file = path.join(resolvedProfileDir, name);
390
+ let raw;
391
+ try {
392
+ raw = await fs.readFile(file, 'utf8');
393
+ } catch {
394
+ scanned.push({ file, validJson: false, hasEnvBlock: false, missing: [...managedKeys] });
395
+ continue;
396
+ }
397
+
398
+ let parsed;
399
+ try {
400
+ parsed = JSON.parse(raw);
401
+ } catch {
402
+ scanned.push({ file, validJson: false, hasEnvBlock: false, missing: [...managedKeys] });
403
+ continue;
404
+ }
405
+
406
+ const envBlock = parsed?.env;
407
+ const hasEnvBlock =
408
+ Boolean(envBlock) && typeof envBlock === 'object' && !Array.isArray(envBlock);
409
+ const missing = hasEnvBlock
410
+ ? managedKeys.filter((key) => !(key in envBlock))
411
+ : [...managedKeys];
412
+
413
+ scanned.push({ file, validJson: true, hasEnvBlock, missing });
414
+ }
415
+
416
+ return {
417
+ profileDir: resolvedProfileDir,
418
+ scanned,
419
+ compliant: scanned.every((entry) => entry.missing.length === 0),
420
+ };
421
+ }
@@ -36,12 +36,15 @@ const STYLE_EXTENSIONS = new Set(['.css', '.scss', '.sass', '.less']);
36
36
  const TRACKED_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.vue', '.sql', '.json', '.yaml', '.yml', '.md', '.sh']);
37
37
  const DISCOVERED_EXTENSIONS = new Set([...TRACKED_EXTENSIONS, ...STYLE_EXTENSIONS]);
38
38
  export const DEFAULT_INDEX_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
39
+ // Bumped only when the snapshot shape itself changes; consumers must reject
40
+ // snapshots from a different version instead of trusting unknown fields.
41
+ export const DISCOVERY_SNAPSHOT_VERSION = 1;
39
42
  const INDEX_PARSE_BATCH_SIZE = 8;
40
43
  const CALL_IGNORE_WORDS = new Set([
41
44
  'if', 'for', 'while', 'switch', 'catch', 'function', 'return', 'typeof',
42
45
  ]);
43
46
 
44
- export async function buildCodeIndex({ rootDir = process.cwd() } = {}) {
47
+ export async function buildCodeIndex({ rootDir = process.cwd(), discoverySnapshot = null } = {}) {
45
48
  const absoluteRoot = path.resolve(rootDir);
46
49
  const indexDir = getIndexDir(absoluteRoot);
47
50
 
@@ -55,7 +58,14 @@ export async function buildCodeIndex({ rootDir = process.cwd() } = {}) {
55
58
  previousCodeFileRecords.map((item) => [item.filePath, { mtimeMs: Number(item.mtimeMs ?? -1), size: Number(item.size ?? -1) }]),
56
59
  );
57
60
 
58
- const discoveredFiles = await discoverProjectFiles(absoluteRoot);
61
+ // Reuse the caller's discovery snapshot when it describes exactly this root
62
+ // under the current schema and snapshot version; anything else (foreign root,
63
+ // stale schema, wrong shape) is rejected and the repository is rediscovered
64
+ // once here. This is what keeps a stale-check + rebuild refresh at a single
65
+ // enumeration instead of two.
66
+ const discoveredFiles = isDiscoverySnapshotUsable(discoverySnapshot, absoluteRoot)
67
+ ? discoverySnapshot.files
68
+ : await discoverProjectFiles(absoluteRoot);
59
69
  const sourceFingerprint = createSourceFingerprint(absoluteRoot, discoveredFiles);
60
70
  const styleFilePaths = discoveredFiles
61
71
  .map((entry) => {
@@ -1071,6 +1081,27 @@ async function discoverProjectFiles(rootDir) {
1071
1081
  return collectFiles([rootDir]);
1072
1082
  }
1073
1083
 
1084
+ function createDiscoverySnapshot(rootDir, files, fingerprint) {
1085
+ // Frozen: the snapshot must stay byte-stable between the staleness inspection
1086
+ // and the build that consumes it, and callers must not mutate shared state.
1087
+ return Object.freeze({
1088
+ snapshotVersion: DISCOVERY_SNAPSHOT_VERSION,
1089
+ schemaVersion: INDEX_SCHEMA_VERSION,
1090
+ rootDir,
1091
+ fingerprint,
1092
+ files: Object.freeze(files.slice()),
1093
+ });
1094
+ }
1095
+
1096
+ function isDiscoverySnapshotUsable(discoverySnapshot, absoluteRoot) {
1097
+ return Boolean(discoverySnapshot)
1098
+ && discoverySnapshot.snapshotVersion === DISCOVERY_SNAPSHOT_VERSION
1099
+ && discoverySnapshot.schemaVersion === INDEX_SCHEMA_VERSION
1100
+ && typeof discoverySnapshot.rootDir === 'string'
1101
+ && path.resolve(discoverySnapshot.rootDir) === absoluteRoot
1102
+ && Array.isArray(discoverySnapshot.files);
1103
+ }
1104
+
1074
1105
  function runGit(rootDir, args) {
1075
1106
  const result = spawnSync('git', args, {
1076
1107
  cwd: rootDir,
@@ -1810,36 +1841,55 @@ export async function getIndexArtifactGeneratedAt({
1810
1841
  return parseArtifactGeneratedAt(artifact);
1811
1842
  }
1812
1843
 
1813
- export async function isIndexStale({
1814
- rootDir = process.cwd(),
1815
- maxAgeMs = DEFAULT_INDEX_CACHE_MAX_AGE_MS,
1816
- now = Date.now(),
1817
- generatedAtMs = null,
1818
- } = {}) {
1844
+ async function evaluateIndexStaleness({ rootDir, maxAgeMs, now, generatedAtMs, enumerateForSnapshot }) {
1819
1845
  const absoluteRoot = path.resolve(rootDir);
1820
1846
  const metaArtifact = await readArtifactIfExists(absoluteRoot, INDEX_ARTIFACTS.meta);
1821
1847
  const effectiveGeneratedAtMs = Number.isFinite(generatedAtMs)
1822
1848
  ? generatedAtMs
1823
1849
  : parseArtifactGeneratedAt(metaArtifact);
1824
- if (effectiveGeneratedAtMs === null) {
1825
- return true;
1826
- }
1827
1850
 
1828
- if (typeof maxAgeMs !== 'number' || Number.isNaN(maxAgeMs) || maxAgeMs < 0) {
1829
- return true;
1830
- }
1851
+ const staleWithoutEnumeration = effectiveGeneratedAtMs === null
1852
+ || typeof maxAgeMs !== 'number'
1853
+ || Number.isNaN(maxAgeMs)
1854
+ || maxAgeMs < 0
1855
+ || (now - effectiveGeneratedAtMs) >= maxAgeMs
1856
+ || !metaArtifact?.sourceFingerprint;
1831
1857
 
1832
- if ((now - effectiveGeneratedAtMs) >= maxAgeMs) {
1833
- return true;
1858
+ // Legacy boolean callers (query-index, triage, verify-context) stop here and
1859
+ // let buildCodeIndex do its own discovery when a rebuild is needed.
1860
+ if (staleWithoutEnumeration && !enumerateForSnapshot) {
1861
+ return { stale: true, snapshot: null };
1834
1862
  }
1835
1863
 
1836
- if (!metaArtifact?.sourceFingerprint) {
1837
- return true;
1838
- }
1864
+ // Exactly one enumeration per inspection. In the refresh flow this snapshot is
1865
+ // handed to buildCodeIndex, so stale-check + rebuild enumerates the repository
1866
+ // once instead of twice.
1867
+ const discoveredFiles = await discoverProjectFiles(absoluteRoot);
1868
+ const fingerprint = createSourceFingerprint(absoluteRoot, discoveredFiles);
1839
1869
 
1840
- const currentSourceFingerprint = createSourceFingerprint(
1841
- absoluteRoot,
1842
- await discoverProjectFiles(absoluteRoot),
1843
- );
1844
- return !areSourceFingerprintsEqual(metaArtifact.sourceFingerprint, currentSourceFingerprint);
1870
+ return {
1871
+ stale: staleWithoutEnumeration
1872
+ ? true
1873
+ : !areSourceFingerprintsEqual(metaArtifact.sourceFingerprint, fingerprint),
1874
+ snapshot: createDiscoverySnapshot(absoluteRoot, discoveredFiles, fingerprint),
1875
+ };
1876
+ }
1877
+
1878
+ export async function inspectIndexStaleness({
1879
+ rootDir = process.cwd(),
1880
+ maxAgeMs = DEFAULT_INDEX_CACHE_MAX_AGE_MS,
1881
+ now = Date.now(),
1882
+ generatedAtMs = null,
1883
+ } = {}) {
1884
+ return evaluateIndexStaleness({ rootDir, maxAgeMs, now, generatedAtMs, enumerateForSnapshot: true });
1885
+ }
1886
+
1887
+ export async function isIndexStale({
1888
+ rootDir = process.cwd(),
1889
+ maxAgeMs = DEFAULT_INDEX_CACHE_MAX_AGE_MS,
1890
+ now = Date.now(),
1891
+ generatedAtMs = null,
1892
+ } = {}) {
1893
+ const { stale } = await evaluateIndexStaleness({ rootDir, maxAgeMs, now, generatedAtMs, enumerateForSnapshot: false });
1894
+ return stale;
1845
1895
  }
@@ -2,13 +2,32 @@
2
2
  # PreToolUse hook: auto-add Bash(<binary>:*) allow rules for frequently used commands.
3
3
  # Settings/usage updates run as ONE locked, atomic read-modify-write (see node block).
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 safe commands still receive managed metadata.
8
25
  if command -v jq >/dev/null 2>&1 && jq --version >/dev/null 2>&1; then
9
26
  COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty')
10
27
  else
11
28
  COMMAND=$(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", () => {
@@ -78,6 +97,10 @@ MAX_RULES="${CLAUDE_AUTO_ALLOW_MAX_RULES:-150}"
78
97
  # Liveness over strictness: if the lock cannot be acquired within maxWaitMs the mutation
79
98
  # runs anyway (pre-lock behaviour) — this hook must never hang a PreToolUse chain.
80
99
  node -e '
100
+ // Deadline must exceed the bounded lock wait in this block (maxWaitMs = 5000) so the
101
+ // watchdog can never fire while the mutation is still legally waiting for the lock.
102
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 8000;
103
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
81
104
  const crypto = require("crypto");
82
105
  const fs = require("fs");
83
106
  const path = require("path");
@@ -14,6 +14,10 @@ fi
14
14
  NOW_UTC=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
15
15
 
16
16
  node -e '
17
+ // Deadline must exceed the bounded lock wait in this block (maxWaitMs = 5000) so the
18
+ // watchdog can never fire while the mutation is still legally waiting for the lock.
19
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 8000;
20
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
17
21
  const crypto = require("crypto");
18
22
  const fs = require("fs");
19
23
  const path = require("path");
@@ -2,13 +2,32 @@
2
2
  # PreToolUse hook: Block dangerous bash commands
3
3
  # Matched on: Bash
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 not installed on stock macOS. Keep jq as the low-latency normal path, but use
7
24
  # UKit's required Node runtime as a fallback so its absence cannot silently disable the gate.
8
25
  if command -v jq >/dev/null 2>&1 && jq --version >/dev/null 2>&1; then
9
26
  COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty')
10
27
  else
11
28
  COMMAND=$(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,24 @@
2
2
  # Stop hook: block premature terminal stops while routed completion evidence is missing.
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): 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/runtime/execution-ledger.mjs"
8
25
 
@@ -11,7 +28,7 @@ if [ ! -f "$SCRIPT" ]; then
11
28
  exit 0
12
29
  fi
13
30
 
14
- OUTPUT=$(printf '%s' "$INPUT" | UKIT_HARNESS=claude-code node "$SCRIPT" --evaluate-stop)
31
+ OUTPUT=$(printf '%s' "$INPUT" | UKIT_HARNESS=claude-code UKIT_HOOK_DEADLINE_MS="${UKIT_HOOK_DEADLINE_MS:-3000}" node "$SCRIPT" --evaluate-stop)
15
32
  STATUS=$?
16
33
 
17
34
  if [ "$STATUS" -ne 0 ]; then
@@ -9,8 +9,23 @@ HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
9
9
  SCRIPT_PATH="$HOOK_DIR/../ukit/runtime/output-compression.mjs"
10
10
 
11
11
  if [ -f "$SCRIPT_PATH" ]; then
12
- INPUT="$(cat)"
13
- printf '%s' "$INPUT" | node "$SCRIPT_PATH"
12
+ # shellcheck source=/dev/null
13
+ if source "$HOOK_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
14
+ trap ukit_cleanup_hook_input EXIT
15
+ # Bounded stdin (H01): compressed tool output can be large, so the staged
16
+ # file is the transport and the shell var only mirrors the capped payload.
17
+ ukit_stage_hook_input 33554432 temp-file || exit 0
18
+ else
19
+ # Runtime helper missing (pre-install tree): bounded inline staging keeps the
20
+ # wrapper's own fail-loud/advisory behavior below alive.
21
+ UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
22
+ head -c 33554433 > "$UKIT_INPUT_FILE" 2>/dev/null
23
+ cat >/dev/null 2>&1
24
+ truncate -s 33554432 "$UKIT_INPUT_FILE" 2>/dev/null
25
+ trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
26
+ fi
27
+ INPUT="$(cat "$UKIT_INPUT_FILE")"
28
+ printf '%s' "$INPUT" | UKIT_HOOK_DEADLINE_MS="${UKIT_HOOK_DEADLINE_MS:-8000}" node "$SCRIPT_PATH"
14
29
  exit $?
15
30
  fi
16
31
 
@@ -29,18 +29,37 @@
29
29
  # Config toggle: compact.hardCapBlock (default true). Set to false only to debug this
30
30
  # gate itself; it must not become a normal escape hatch.
31
31
 
32
- INPUT=$(cat)
33
32
  PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
34
33
  HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
34
+ # shellcheck source=/dev/null
35
+ if source "$HOOK_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
36
+ trap ukit_cleanup_hook_input EXIT
37
+ # Bounded stdin (H01): stage before anything reads it; Node gets a path, never the payload.
38
+ ukit_stage_hook_input 2097152 truncate || exit 0
39
+ else
40
+ # Runtime helper missing (pre-install tree): bounded inline staging keeps the
41
+ # advisory/fail-open behavior below alive with a capped payload file.
42
+ UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
43
+ head -c 2097153 > "$UKIT_INPUT_FILE" 2>/dev/null
44
+ cat >/dev/null 2>&1
45
+ if [ "$(wc -c < "$UKIT_INPUT_FILE" | tr -d '[:space:]')" -gt 2097152 ]; then
46
+ truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
47
+ fi
48
+ trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
49
+ fi
35
50
 
36
- INPUT="$INPUT" PROJECT_ROOT="$PROJECT_ROOT" HOOK_DIR="$HOOK_DIR" node <<'NODE'
51
+ INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" HOOK_DIR="$HOOK_DIR" node <<'NODE'
52
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
53
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
37
54
  const fs = require('fs');
38
55
  const path = require('path');
39
56
  const { pathToFileURL } = require('url');
40
57
 
58
+ let rawInput = '';
59
+ try { rawInput = fs.readFileSync(process.env.INPUT_FILE || '', 'utf8'); } catch {}
41
60
  const payload = (() => {
42
61
  try {
43
- const parsed = JSON.parse(process.env.INPUT || '');
62
+ const parsed = JSON.parse(rawInput);
44
63
  return parsed && typeof parsed === 'object' ? parsed : {};
45
64
  } catch {
46
65
  return {};