@ngockhoale/ukit 2.4.1 → 2.4.3

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 (56) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/manifests/platform.full.yaml +19 -111
  3. package/package.json +2 -1
  4. package/scripts/index/refresh-index.mjs +48 -18
  5. package/src/cli/commands/doctor.js +59 -2
  6. package/src/core/compact/threshold.js +36 -6
  7. package/src/core/gatewayProbe.js +143 -15
  8. package/src/core/gatewayResilienceEnv.js +136 -7
  9. package/src/diagnostics/classifyHang.js +246 -0
  10. package/src/index/buildIndex.js +1096 -75
  11. package/templates/.claude/hooks/auto-allow-bash.sh +99 -87
  12. package/templates/.claude/hooks/auto-prune-bash.sh +4 -0
  13. package/templates/.claude/hooks/block-dangerous.sh +46 -1
  14. package/templates/.claude/hooks/completion-gate.sh +65 -7
  15. package/templates/.claude/hooks/compress-output.sh +49 -2
  16. package/templates/.claude/hooks/context-hardcap-gate.sh +52 -4
  17. package/templates/.claude/hooks/context-window-guard.sh +204 -71
  18. package/templates/.claude/hooks/handoff-model-guard.sh +50 -3
  19. package/templates/.claude/hooks/handoff-resume.sh +47 -3
  20. package/templates/.claude/hooks/post-edit-verify.sh +45 -2
  21. package/templates/.claude/hooks/pre-edit-backup.sh +45 -2
  22. package/templates/.claude/hooks/protect-files.sh +46 -1
  23. package/templates/.claude/hooks/record-execution.sh +46 -2
  24. package/templates/.claude/hooks/reinject-context.sh +1 -1
  25. package/templates/.claude/hooks/reset-compact-pressure.sh +4 -0
  26. package/templates/.claude/hooks/sensitive-data-guard.sh +101 -18
  27. package/templates/.claude/hooks/skill-router.sh +59 -5
  28. package/templates/.claude/hooks/stale-spec-guard.sh +47 -2
  29. package/templates/.claude/hooks/task-watchdog.sh +129 -126
  30. package/templates/.claude/hooks/verification-guard.sh +136 -106
  31. package/templates/.claude/hooks/vision-router.sh +138 -18
  32. package/templates/.claude/settings.json +0 -5
  33. package/templates/.claude/ukit/index/lib/index-core.mjs +1027 -68
  34. package/templates/.claude/ukit/index/post-edit-verify.mjs +8 -0
  35. package/templates/.claude/ukit/index/pre-edit-backup.mjs +8 -0
  36. package/templates/.claude/ukit/index/refresh-index.mjs +48 -18
  37. package/templates/.claude/ukit/index/route-task.mjs +610 -4
  38. package/templates/.claude/ukit/index/stale-spec-check.mjs +8 -0
  39. package/templates/.claude/ukit/runtime/async-lock.mjs +340 -0
  40. package/templates/.claude/ukit/runtime/compact-threshold.mjs +73 -24
  41. package/templates/.claude/ukit/runtime/context-capacity.mjs +144 -0
  42. package/templates/.claude/ukit/runtime/execution-ledger.mjs +672 -170
  43. package/templates/.claude/ukit/runtime/hook-chain-budget.mjs +92 -0
  44. package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +84 -29
  45. package/templates/.claude/ukit/runtime/hook-input.mjs +120 -0
  46. package/templates/.claude/ukit/runtime/hook-input.sh +140 -0
  47. package/templates/.claude/ukit/runtime/hook-payload-store.mjs +160 -0
  48. package/templates/.claude/ukit/runtime/hook-process.mjs +250 -0
  49. package/templates/.claude/ukit/runtime/hook-telemetry.mjs +255 -0
  50. package/templates/.claude/ukit/runtime/hook-telemetry.sh +60 -0
  51. package/templates/.claude/ukit/runtime/output-compression.mjs +8 -0
  52. package/templates/.claude/ukit/runtime/reinject-context.mjs +8 -0
  53. package/templates/.claude/ukit/runtime/stop-coordinator.mjs +509 -0
  54. package/templates/.claude/ukit/runtime/task-watchdog.mjs +180 -6
  55. package/templates/.claude/ukit/runtime/transcript-tail.mjs +107 -0
  56. package/templates/.omp/hooks/pre/ukit-bridge.js +171 -57
@@ -2,13 +2,58 @@
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
+ # TASK-019: arm advisory latency telemetry; the finish row is written by the
9
+ # shared cleanup path before the staged payload is removed. A missing
10
+ # runtime (pre-install tree) simply leaves telemetry off.
11
+ source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
12
+ trap ukit_cleanup_hook_input EXIT
13
+ # Bounded stdin (H01): stage before anything reads it; the shell var is capped.
14
+ ukit_stage_hook_input 2097152 truncate || exit 0
15
+ else
16
+ # Runtime helper missing (pre-install tree): the SAME bounded staging,
17
+ # inline - `cat >/dev/null` used to block forever on a producer that never
18
+ # closes the pipe (R4.5). Read cap+1 in the background, bound the wait.
19
+ UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
20
+ if [ -e /dev/fd/0 ]; then
21
+ exec 8<&0
22
+ head -c 2097153 <&8 > "$UKIT_INPUT_FILE" 2>/dev/null &
23
+ else
24
+ head -c 2097153 > "$UKIT_INPUT_FILE" 2>/dev/null &
25
+ fi
26
+ __ukit_reader=$!
27
+ __ukit_stage_ms="${UKIT_HOOK_STAGE_MS:-2000}"
28
+ case "$__ukit_stage_ms" in
29
+ ''|*[!0-9]*) __ukit_stage_ms=2000 ;;
30
+ esac
31
+ [ "$__ukit_stage_ms" -gt 0 ] 2>/dev/null || __ukit_stage_ms=2000
32
+ printf -v __ukit_stage_s '%d.%03d' $((__ukit_stage_ms / 1000)) $((__ukit_stage_ms % 1000))
33
+ ( sleep "$__ukit_stage_s" 2>/dev/null; kill -9 "$__ukit_reader" 2>/dev/null ) <&- >/dev/null 2>&1 &
34
+ __ukit_waiter=$!
35
+ wait "$__ukit_reader" 2>/dev/null
36
+ __ukit_stage_rc=$?
37
+ kill "$__ukit_waiter" 2>/dev/null
38
+ wait "$__ukit_waiter" 2>/dev/null
39
+ exec 8<&-
40
+ __ukit_size="$(wc -c < "$UKIT_INPUT_FILE" 2>/dev/null | tr -d '[:space:]')"
41
+ # A bounded cut (rc != 0) leaves a short payload that the JSON consumer
42
+ # already degrades on - the shared helper's truncate posture (R4.5).
43
+ if [ "$__ukit_size" -gt 2097152 ]; then
44
+ truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
45
+ fi
46
+ trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
47
+ fi
48
+ INPUT="$(cat "$UKIT_INPUT_FILE")"
6
49
  # jq is absent on stock macOS. Keep jq as the low-latency normal path, but fall back to
7
50
  # UKit's required Node runtime so a missing optional binary cannot disable this gate.
8
51
  if command -v jq >/dev/null 2>&1 && jq --version >/dev/null 2>&1; then
9
52
  FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty')
10
53
  else
11
54
  FILE_PATH=$(printf '%s' "$INPUT" | node -e '
55
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 3000;
56
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
12
57
  const chunks = [];
13
58
  process.stdin.on("data", (chunk) => chunks.push(chunk));
14
59
  process.stdin.on("end", () => {
@@ -2,7 +2,51 @@
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
+ # TASK-019: arm advisory latency telemetry; the finish row is written by the
9
+ # shared cleanup path before the staged payload is removed. A missing
10
+ # runtime (pre-install tree) simply leaves telemetry off.
11
+ source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
12
+ trap ukit_cleanup_hook_input EXIT
13
+ # Bounded stdin (H01): receipts can legitimately be large, so the staged file
14
+ # is the transport and the shell var only mirrors the capped payload.
15
+ ukit_stage_hook_input 33554432 temp-file || exit 0
16
+ else
17
+ # Runtime helper missing (pre-install tree): the SAME bounded staging,
18
+ # inline - `cat >/dev/null` used to block forever on a producer that never
19
+ # closes the pipe (R4.5). Read cap+1 in the background, bound the wait.
20
+ UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
21
+ if [ -e /dev/fd/0 ]; then
22
+ exec 8<&0
23
+ head -c 33554433 <&8 > "$UKIT_INPUT_FILE" 2>/dev/null &
24
+ else
25
+ head -c 33554433 > "$UKIT_INPUT_FILE" 2>/dev/null &
26
+ fi
27
+ __ukit_reader=$!
28
+ __ukit_stage_ms="${UKIT_HOOK_STAGE_MS:-2000}"
29
+ case "$__ukit_stage_ms" in
30
+ ''|*[!0-9]*) __ukit_stage_ms=2000 ;;
31
+ esac
32
+ [ "$__ukit_stage_ms" -gt 0 ] 2>/dev/null || __ukit_stage_ms=2000
33
+ printf -v __ukit_stage_s '%d.%03d' $((__ukit_stage_ms / 1000)) $((__ukit_stage_ms % 1000))
34
+ ( sleep "$__ukit_stage_s" 2>/dev/null; kill -9 "$__ukit_reader" 2>/dev/null ) <&- >/dev/null 2>&1 &
35
+ __ukit_waiter=$!
36
+ wait "$__ukit_reader" 2>/dev/null
37
+ __ukit_stage_rc=$?
38
+ kill "$__ukit_waiter" 2>/dev/null
39
+ wait "$__ukit_waiter" 2>/dev/null
40
+ exec 8<&-
41
+ __ukit_size="$(wc -c < "$UKIT_INPUT_FILE" 2>/dev/null | tr -d '[:space:]')"
42
+ # A bounded cut (rc != 0) leaves a short payload that the JSON consumer
43
+ # already degrades on - the shared helper's truncate posture (R4.5).
44
+ if [ "$__ukit_size" -gt 33554432 ]; then
45
+ truncate -s 33554432 "$UKIT_INPUT_FILE" 2>/dev/null
46
+ fi
47
+ trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
48
+ fi
49
+ INPUT="$(cat "$UKIT_INPUT_FILE")"
6
50
  PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
7
51
  SCRIPT="$PROJECT_ROOT/.claude/ukit/runtime/execution-ledger.mjs"
8
52
 
@@ -11,7 +55,7 @@ if [ ! -f "$SCRIPT" ]; then
11
55
  exit 0
12
56
  fi
13
57
 
14
- OUTPUT=$(printf '%s' "$INPUT" | UKIT_HARNESS=claude-code node "$SCRIPT" --record)
58
+ OUTPUT=$(printf '%s' "$INPUT" | UKIT_HARNESS=claude-code UKIT_HOOK_DEADLINE_MS="${UKIT_HOOK_DEADLINE_MS:-3000}" node "$SCRIPT" --record)
15
59
  STATUS=$?
16
60
 
17
61
  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,13 +2,17 @@
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,
10
13
  # URL-embedded credentials, any high-confidence token in the command text)
11
- # 3. UserPromptSubmit prompt text containing high-confidence secret patterns
14
+ # 3. UserPromptSubmit prompt text containing high-confidence secret patterns;
15
+ # complete machine-generated envelopes are delivered with a redacted advisory
12
16
  #
13
17
  # FAIL-CLOSED on detection (exit 2). Never echoes the detected value — only a
14
18
  # redacted preview plus a truncated sha256 (12 hex chars: enough for the human
@@ -22,17 +26,70 @@
22
26
  # Fails open only when there is nothing to scan (malformed stdin, no channel):
23
27
  # a broken gate must not brick every session, but every real detection blocks.
24
28
 
25
- INPUT=$(cat)
29
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
26
30
  PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
31
+ # Bounded stdin (H01) — fail-closed overflow: an oversized payload is REFUSED
32
+ # (silent exit 2, nothing staged, nothing echoed). The gate never truncates
33
+ # unknown overflow and never materializes it in a shell variable.
34
+ # shellcheck source=/dev/null
35
+ if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
36
+ # TASK-019: arm advisory latency telemetry; the finish row is written by the
37
+ # shared cleanup path before the staged payload is removed. A missing
38
+ # runtime (pre-install tree) simply leaves telemetry off.
39
+ source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
40
+ trap ukit_cleanup_hook_input EXIT
41
+ ukit_stage_hook_input 2097152 refuse
42
+ STAGE_RC=$?
43
+ if [ "$STAGE_RC" -ne 0 ]; then
44
+ exit "$STAGE_RC"
45
+ fi
46
+ else
47
+ # Runtime helper missing (pre-install tree): the SAME bounded staging,
48
+ # inline - `cat >/dev/null` used to block forever on a producer that never
49
+ # closes the pipe (R4.5). Read cap+1 in the background, bound the wait.
50
+ UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
51
+ if [ -e /dev/fd/0 ]; then
52
+ exec 8<&0
53
+ head -c 2097153 <&8 > "$UKIT_INPUT_FILE" 2>/dev/null &
54
+ else
55
+ head -c 2097153 > "$UKIT_INPUT_FILE" 2>/dev/null &
56
+ fi
57
+ __ukit_reader=$!
58
+ __ukit_stage_ms="${UKIT_HOOK_STAGE_MS:-2000}"
59
+ case "$__ukit_stage_ms" in
60
+ ''|*[!0-9]*) __ukit_stage_ms=2000 ;;
61
+ esac
62
+ [ "$__ukit_stage_ms" -gt 0 ] 2>/dev/null || __ukit_stage_ms=2000
63
+ printf -v __ukit_stage_s '%d.%03d' $((__ukit_stage_ms / 1000)) $((__ukit_stage_ms % 1000))
64
+ ( sleep "$__ukit_stage_s" 2>/dev/null; kill -9 "$__ukit_reader" 2>/dev/null ) <&- >/dev/null 2>&1 &
65
+ __ukit_waiter=$!
66
+ wait "$__ukit_reader" 2>/dev/null
67
+ __ukit_stage_rc=$?
68
+ kill "$__ukit_waiter" 2>/dev/null
69
+ wait "$__ukit_waiter" 2>/dev/null
70
+ exec 8<&-
71
+ __ukit_size="$(wc -c < "$UKIT_INPUT_FILE" 2>/dev/null | tr -d '[:space:]')"
72
+ if [ "$__ukit_stage_rc" -ne 0 ] || [ "$__ukit_size" -gt 2097152 ]; then
73
+ # Fail-closed: an unstaged/oversized payload was never inspected
74
+ # (exit 2 blocks; 1 would be a non-blocking hook error).
75
+ rm -f "$UKIT_INPUT_FILE"
76
+ exit 2
77
+ fi
78
+ trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
79
+ fi
27
80
 
28
- INPUT="$INPUT" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE'
81
+ INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE'
82
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
83
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
29
84
  const fs = require('fs');
30
85
  const path = require('path');
31
86
  const { createHash } = require('crypto');
32
87
 
88
+ let rawInput = '';
89
+ try { rawInput = fs.readFileSync(process.env.INPUT_FILE || '', 'utf8'); } catch {}
33
90
  const payload = (() => {
34
91
  try {
35
- const parsed = JSON.parse(process.env.INPUT || '');
92
+ const parsed = JSON.parse(rawInput);
36
93
  return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
37
94
  } catch {
38
95
  return {};
@@ -76,22 +133,26 @@ function pathAllowed(filePath) {
76
133
  }
77
134
 
78
135
  // --- high-confidence secret value patterns (shared by prompt + bash scanning) ---
136
+ // AKIA/ASIA/AIza prefixes are pure base64-compatible text, so an encoded blob
137
+ // can contain a coincidental key-shaped substring (round-17 live case: ASIA…
138
+ // inside a base64 payload). Those rules must stand alone — no base64/base64url
139
+ // character on either side. Padding "=" is a boundary: it only ends a blob.
79
140
  const TOKEN_PATTERNS = [
80
141
  { 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 },
142
+ { label: 'AWS access key id', re: /(?<![A-Za-z0-9+/_-])(?:AKIA|ASIA)[0-9A-Z]{16}(?![A-Za-z0-9+/_-])/g },
82
143
  { label: 'GitHub token', re: /\bgh[pousr]_[A-Za-z0-9]{30,}\b/g },
83
144
  { label: 'GitHub fine-grained token', re: /\bgithub_pat_[A-Za-z0-9_]{20,}/g },
84
145
  { label: 'GitLab token', re: /\bglpat-[A-Za-z0-9_-]{20,}/g },
85
146
  { 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 },
147
+ { label: 'Google API key', re: /(?<![A-Za-z0-9+/_-])AIza[0-9A-Za-z_-]{20,}(?![A-Za-z0-9+/_-])/g },
87
148
  { label: 'Stripe live key', re: /\b[srp]k_live_[A-Za-z0-9]{20,}/g },
88
149
  { label: 'JWT', re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g },
89
150
  { 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
151
  ];
92
152
 
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
-
153
+ // No keyword-assignment lane ("password: …", "token = …") on purpose — it was
154
+ // the source of every false positive: it guessed at whatever followed a
155
+ // keyword, blocking code, prose and shell syntax that is never a secret.
95
156
  function scanSecretText(text) {
96
157
  const found = [];
97
158
  for (const { label, re } of TOKEN_PATTERNS) {
@@ -101,11 +162,6 @@ function scanSecretText(text) {
101
162
  found.push({ label, value: match[0] });
102
163
  }
103
164
  }
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
165
  return found;
110
166
  }
111
167
 
@@ -253,6 +309,32 @@ if (findings.length === 0) {
253
309
  process.exit(0);
254
310
  }
255
311
 
312
+ // --- machine-generated envelope lane (UserPromptSubmit only) ---
313
+ // Harness plumbing (<task-notification>, <system-reminder>,
314
+ // <cross-session-message>) legitimately carries fixture/log content through
315
+ // the prompt channel — test tokens quoted in task bodies, CI logs, agent
316
+ // results. Blocking it silently drops the notification and stalls pipelines
317
+ // ("Waiting for N background agents"). These envelopes flow with a redacted
318
+ // advisory: labels + counts only, never values. Everything a human types —
319
+ // and every other event/channel — stays fail-closed below.
320
+ const MACHINE_ENVELOPE_RE = /^(?:task-notification|system-reminder|cross-session-message)$/i;
321
+ function isCompleteMachineEnvelope(text) {
322
+ const trimmed = String(text || '').trim();
323
+ const opening = /^<([a-z-]+)(?:\s[^>]*)?>/i.exec(trimmed);
324
+ if (!opening || !MACHINE_ENVELOPE_RE.test(opening[1])) return false;
325
+ const tag = opening[1];
326
+ const closing = new RegExp(`</${tag}>$`, 'i');
327
+ return closing.test(trimmed);
328
+ }
329
+ const promptText = textChannels.length > 0 ? textChannels[0].text : null;
330
+ if (event === 'UserPromptSubmit' && promptText && isCompleteMachineEnvelope(promptText)) {
331
+ const labels = [...new Set(findings.map((f) => f.label))];
332
+ process.stdout.write(`${JSON.stringify({
333
+ 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.`,
334
+ })}\n`);
335
+ process.exit(0);
336
+ }
337
+
256
338
  // --- block message: redacted previews only, never the secret itself ---
257
339
  function describeFinding(finding) {
258
340
  const where = finding.preview
@@ -264,8 +346,9 @@ function describeFinding(finding) {
264
346
  const lines = [
265
347
  `BLOCKED (sensitive data): ${findings.length} potential secret(s) detected. Nothing was sent to the AI.`,
266
348
  ...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:',
349
+ 'Detection is format-true only: a match must itself be a vendor-formatted key,',
350
+ 'a private key block, a JWT, or a credentials file. Code, prose, hashes and',
351
+ 'encoded blobs are not blocked. To proceed, the USER can choose one of:',
269
352
  ' 1. Redact the secret (placeholder like <API_KEY>) and retry.',
270
353
  ' 2. Approve it explicitly: add the full sha256 (shasum -a 256 of the value) or the file path',
271
354
  ' to .ukit/storage/security/allowlist.json — that file is protected from AI edits.',
@@ -2,16 +2,63 @@
2
2
  # Hook: route prompt/tool signals to the smallest useful installed skill set.
3
3
  # Used from UserPromptSubmit and PreToolUse so end users do not need to name skills.
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
+ # TASK-019: arm advisory latency telemetry; the finish row is written by the
9
+ # shared cleanup path before the staged payload is removed. A missing
10
+ # runtime (pre-install tree) simply leaves telemetry off.
11
+ source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
12
+ trap ukit_cleanup_hook_input EXIT
13
+ # Bounded stdin (H01): stage before anything reads it; the shell var is capped,
14
+ # env passes only the staged-file path — never the payload.
15
+ ukit_stage_hook_input 2097152 truncate || exit 0
16
+ else
17
+ # Runtime helper missing (pre-install tree): the SAME bounded staging,
18
+ # inline - `cat >/dev/null` used to block forever on a producer that never
19
+ # closes the pipe (R4.5). Read cap+1 in the background, bound the wait.
20
+ UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
21
+ if [ -e /dev/fd/0 ]; then
22
+ exec 8<&0
23
+ head -c 2097153 <&8 > "$UKIT_INPUT_FILE" 2>/dev/null &
24
+ else
25
+ head -c 2097153 > "$UKIT_INPUT_FILE" 2>/dev/null &
26
+ fi
27
+ __ukit_reader=$!
28
+ __ukit_stage_ms="${UKIT_HOOK_STAGE_MS:-2000}"
29
+ case "$__ukit_stage_ms" in
30
+ ''|*[!0-9]*) __ukit_stage_ms=2000 ;;
31
+ esac
32
+ [ "$__ukit_stage_ms" -gt 0 ] 2>/dev/null || __ukit_stage_ms=2000
33
+ printf -v __ukit_stage_s '%d.%03d' $((__ukit_stage_ms / 1000)) $((__ukit_stage_ms % 1000))
34
+ ( sleep "$__ukit_stage_s" 2>/dev/null; kill -9 "$__ukit_reader" 2>/dev/null ) <&- >/dev/null 2>&1 &
35
+ __ukit_waiter=$!
36
+ wait "$__ukit_reader" 2>/dev/null
37
+ __ukit_stage_rc=$?
38
+ kill "$__ukit_waiter" 2>/dev/null
39
+ wait "$__ukit_waiter" 2>/dev/null
40
+ exec 8<&-
41
+ __ukit_size="$(wc -c < "$UKIT_INPUT_FILE" 2>/dev/null | tr -d '[:space:]')"
42
+ # A bounded cut (rc != 0) leaves a short payload that the JSON consumer
43
+ # already degrades on - the shared helper's truncate posture (R4.5).
44
+ if [ "$__ukit_size" -gt 2097152 ]; then
45
+ truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
46
+ fi
47
+ trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
48
+ fi
49
+ INPUT="$(cat "$UKIT_INPUT_FILE")"
6
50
  PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
7
51
 
8
52
  # Internal orchestration envelopes are not user requests. Ignore them before routing or
9
53
  # compact-pressure bookkeeping so task results cannot replace the active completion contract.
10
54
  case "$INPUT" in
11
55
  *"<task-notification"*|*"<tool-result"*|*"<system-reminder"*|*"<local-command-caveat"*)
12
- INTERNAL_ORCHESTRATION=$(INPUT="$INPUT" node -e '
56
+ INTERNAL_ORCHESTRATION=$(INPUT_FILE="$UKIT_INPUT_FILE" node -e '
57
+ // wall-clock watchdog: never let a wedged parse outlive the hook (same arm as the router heredoc below)
58
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 3000;
59
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
13
60
  try {
14
- const payload = JSON.parse(process.env.INPUT || "{}");
61
+ const payload = JSON.parse(require("fs").readFileSync(process.env.INPUT_FILE || "", "utf8") || "{}");
15
62
  const candidates = [payload?.prompt, payload?.user_prompt, payload?.text, payload?.message, payload?.input]
16
63
  .filter((value) => typeof value === "string")
17
64
  .map((value) => value.trim());
@@ -44,6 +91,9 @@ esac
44
91
  case "$INPUT" in
45
92
  *".worktrees/"*)
46
93
  if printf '%s' "$INPUT" | node -e '
94
+ // wall-clock watchdog: never let a wedged stdin read outlive the hook (same arm as the router heredoc below)
95
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 3000;
96
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
47
97
  const chunks = [];
48
98
  process.stdin.on("data", (c) => chunks.push(c));
49
99
  process.stdin.on("end", () => {
@@ -79,6 +129,9 @@ THRESHOLD_SCRIPT="$HOOK_DIR/../ukit/runtime/compact-threshold.mjs"
79
129
  case "$INPUT" in
80
130
  *'"agent_id"'*|*'"agent_type"'*)
81
131
  if printf '%s' "$INPUT" | node -e '
132
+ // wall-clock watchdog: never let a wedged stdin read outlive the hook (same arm as the router heredoc below)
133
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 3000;
134
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
82
135
  const chunks = [];
83
136
  process.stdin.on("data", (c) => chunks.push(c));
84
137
  process.stdin.on("end", () => {
@@ -96,7 +149,7 @@ process.stdin.on("end", () => {
96
149
  ;;
97
150
  esac
98
151
 
99
- INPUT="$INPUT" PROJECT_ROOT="$PROJECT_ROOT" STATE_FILE="$STATE_FILE" HOOK_DIR="$HOOK_DIR" node <<'NODE'
152
+ INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" STATE_FILE="$STATE_FILE" HOOK_DIR="$HOOK_DIR" node <<'NODE'
100
153
  const fs = require('fs');
101
154
  const path = require('path');
102
155
  const crypto = require('crypto');
@@ -2566,7 +2619,8 @@ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
2566
2619
  return compactState;
2567
2620
  }
2568
2621
 
2569
- const rawInput = process.env.INPUT || '';
2622
+ let rawInput = '';
2623
+ try { rawInput = fs.readFileSync(process.env.INPUT_FILE || '', 'utf8'); } catch {}
2570
2624
  const projectRoot = process.env.PROJECT_ROOT || process.cwd();
2571
2625
  const statePath = process.env.STATE_FILE || path.join(projectRoot, '.claude', 'ukit', 'skill-router-state.json');
2572
2626
  const routeCachePath = path.join(projectRoot, '.claude', 'ukit', 'route-cache.json');
@@ -2,7 +2,50 @@
2
2
  # PreToolUse hook: prevent stale/ambiguous risky Edit specs and whole-file risky Write.
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
+ # TASK-019: arm advisory latency telemetry; the finish row is written by the
9
+ # shared cleanup path before the staged payload is removed. A missing
10
+ # runtime (pre-install tree) simply leaves telemetry off.
11
+ source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
12
+ trap ukit_cleanup_hook_input EXIT
13
+ # Bounded stdin (H01): stage before anything reads it; the shell var is capped.
14
+ ukit_stage_hook_input 2097152 truncate || exit 0
15
+ else
16
+ # Runtime helper missing (pre-install tree): the SAME bounded staging,
17
+ # inline - `cat >/dev/null` used to block forever on a producer that never
18
+ # closes the pipe (R4.5). Read cap+1 in the background, bound the wait.
19
+ UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
20
+ if [ -e /dev/fd/0 ]; then
21
+ exec 8<&0
22
+ head -c 2097153 <&8 > "$UKIT_INPUT_FILE" 2>/dev/null &
23
+ else
24
+ head -c 2097153 > "$UKIT_INPUT_FILE" 2>/dev/null &
25
+ fi
26
+ __ukit_reader=$!
27
+ __ukit_stage_ms="${UKIT_HOOK_STAGE_MS:-2000}"
28
+ case "$__ukit_stage_ms" in
29
+ ''|*[!0-9]*) __ukit_stage_ms=2000 ;;
30
+ esac
31
+ [ "$__ukit_stage_ms" -gt 0 ] 2>/dev/null || __ukit_stage_ms=2000
32
+ printf -v __ukit_stage_s '%d.%03d' $((__ukit_stage_ms / 1000)) $((__ukit_stage_ms % 1000))
33
+ ( sleep "$__ukit_stage_s" 2>/dev/null; kill -9 "$__ukit_reader" 2>/dev/null ) <&- >/dev/null 2>&1 &
34
+ __ukit_waiter=$!
35
+ wait "$__ukit_reader" 2>/dev/null
36
+ __ukit_stage_rc=$?
37
+ kill "$__ukit_waiter" 2>/dev/null
38
+ wait "$__ukit_waiter" 2>/dev/null
39
+ exec 8<&-
40
+ __ukit_size="$(wc -c < "$UKIT_INPUT_FILE" 2>/dev/null | tr -d '[:space:]')"
41
+ # A bounded cut (rc != 0) leaves a short payload that the JSON consumer
42
+ # already degrades on - the shared helper's truncate posture (R4.5).
43
+ if [ "$__ukit_size" -gt 2097152 ]; then
44
+ truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
45
+ fi
46
+ trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
47
+ fi
48
+ INPUT="$(cat "$UKIT_INPUT_FILE")"
6
49
  PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
7
50
  SCRIPT="$PROJECT_ROOT/.claude/ukit/index/stale-spec-check.mjs"
8
51
 
@@ -21,6 +64,8 @@ fi
21
64
  case "$INPUT" in
22
65
  *".worktrees/"*)
23
66
  if printf '%s' "$INPUT" | node -e '
67
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 3000;
68
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
24
69
  const chunks = [];
25
70
  process.stdin.on("data", (c) => chunks.push(c));
26
71
  process.stdin.on("end", () => {
@@ -42,4 +87,4 @@ process.stdin.on("end", () => {
42
87
  ;;
43
88
  esac
44
89
 
45
- printf '%s' "$INPUT" | node "$SCRIPT"
90
+ printf '%s' "$INPUT" | UKIT_HOOK_DEADLINE_MS="${UKIT_HOOK_DEADLINE_MS:-3000}" node "$SCRIPT"