@ngockhoale/ukit 2.4.2 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +20 -0
  3. package/manifests/platform.full.yaml +51 -112
  4. package/package.json +2 -1
  5. package/scripts/index/refresh-index.mjs +47 -22
  6. package/src/cli/commands/doctor.js +132 -2
  7. package/src/cli/commands/uninstall.js +18 -0
  8. package/src/core/applyPlan.js +17 -2
  9. package/src/core/compact/threshold.js +36 -6
  10. package/src/core/diffPlan.js +35 -0
  11. package/src/core/fileOps.js +26 -0
  12. package/src/core/projectImportant.js +430 -0
  13. package/src/core/sensitiveValueScanner.js +118 -0
  14. package/src/core/status.js +55 -1
  15. package/src/core/uninstall.js +183 -3
  16. package/src/diagnostics/classifyHang.js +246 -0
  17. package/src/index/buildIndex.js +1033 -62
  18. package/templates/.claude/hooks/auto-allow-bash.sh +82 -93
  19. package/templates/.claude/hooks/block-dangerous.sh +31 -5
  20. package/templates/.claude/hooks/completion-gate.sh +51 -10
  21. package/templates/.claude/hooks/compress-output.sh +38 -6
  22. package/templates/.claude/hooks/context-hardcap-gate.sh +35 -6
  23. package/templates/.claude/hooks/context-window-guard.sh +128 -18
  24. package/templates/.claude/hooks/handoff-model-guard.sh +31 -5
  25. package/templates/.claude/hooks/handoff-resume.sh +31 -5
  26. package/templates/.claude/hooks/post-edit-verify.sh +31 -5
  27. package/templates/.claude/hooks/pre-edit-backup.sh +31 -5
  28. package/templates/.claude/hooks/project-important.sh +67 -0
  29. package/templates/.claude/hooks/protect-files.sh +31 -5
  30. package/templates/.claude/hooks/record-execution.sh +31 -5
  31. package/templates/.claude/hooks/sensitive-data-guard.sh +124 -56
  32. package/templates/.claude/hooks/skill-router.sh +31 -5
  33. package/templates/.claude/hooks/stale-spec-guard.sh +31 -5
  34. package/templates/.claude/hooks/task-watchdog.sh +108 -123
  35. package/templates/.claude/hooks/verification-guard.sh +107 -112
  36. package/templates/.claude/hooks/vision-router.sh +49 -13
  37. package/templates/.claude/settings.json +5 -5
  38. package/templates/.claude/ukit/index/lib/index-core.mjs +960 -63
  39. package/templates/.claude/ukit/index/refresh-index.mjs +47 -22
  40. package/templates/.claude/ukit/index/route-task.mjs +610 -4
  41. package/templates/.claude/ukit/runtime/async-lock.mjs +340 -0
  42. package/templates/.claude/ukit/runtime/compact-threshold.mjs +73 -24
  43. package/templates/.claude/ukit/runtime/context-capacity.mjs +144 -0
  44. package/templates/.claude/ukit/runtime/execution-ledger.mjs +664 -170
  45. package/templates/.claude/ukit/runtime/hook-chain-budget.mjs +92 -0
  46. package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +84 -29
  47. package/templates/.claude/ukit/runtime/hook-input.sh +85 -5
  48. package/templates/.claude/ukit/runtime/hook-payload-store.mjs +160 -0
  49. package/templates/.claude/ukit/runtime/hook-process.mjs +250 -0
  50. package/templates/.claude/ukit/runtime/hook-telemetry.mjs +255 -0
  51. package/templates/.claude/ukit/runtime/hook-telemetry.sh +60 -0
  52. package/templates/.claude/ukit/runtime/project-important.mjs +381 -0
  53. package/templates/.claude/ukit/runtime/sensitive-value-scanner.mjs +128 -0
  54. package/templates/.claude/ukit/runtime/stop-coordinator.mjs +509 -0
  55. package/templates/.claude/ukit/runtime/task-watchdog.mjs +180 -6
  56. package/templates/.claude/ukit/runtime/transcript-tail.mjs +1 -1
  57. package/templates/.omp/hooks/pre/ukit-bridge.js +178 -61
  58. package/templates/AGENTS.md +8 -0
  59. package/templates/PROJECT_IMPORTANT.md +9 -0
@@ -0,0 +1,92 @@
1
+ // hook-chain-budget.mjs — single source of truth for the PreToolUse chain budget.
2
+ //
3
+ // TASK-018 review fix round 1. The chain has TWO deadlines and they must never
4
+ // disagree:
5
+ // - INNER (hook-chain-runner.mjs): the chain's own total budget, plus the
6
+ // per-child budget every script may use.
7
+ // - OUTER (ukit-bridge.js): the `pi.exec` timeout that supervises the runner
8
+ // process itself.
9
+ //
10
+ // When the outer timeout was a separate hardcoded formula (10s/4s/16s under a
11
+ // fixed 30s cap) while the runner honored UKIT_HOOK_CHAIN_BASE_MS /
12
+ // UKIT_HOOK_CHILD_BUDGET_MS / UKIT_HOOK_CHAIN_CEILING_MS, a chain configured for
13
+ // 60-120s was killed by the bridge at 14-20s. The killed runner produced no
14
+ // verifiable per-script verdict, which is a transport failure — and for
15
+ // Edit|Write that is fail-closed, so every edit was blocked until the operator
16
+ // removed the env var. Both sides now resolve the same numbers here.
17
+ //
18
+ // The clamps are intentionally generous (up to 120s) because they are an
19
+ // explicit operator/test affordance, not a default: an unconfigured chain still
20
+ // resolves to the 10s/4s/16s defaults that keep a PreToolUse call inside omp's
21
+ // own handler budget.
22
+
23
+ export const CHAIN_BUDGET_SPECS = {
24
+ base: { name: 'UKIT_HOOK_CHAIN_BASE_MS', fallback: 10000, min: 500, max: 60000 },
25
+ child: { name: 'UKIT_HOOK_CHILD_BUDGET_MS', fallback: 4000, min: 100, max: 60000 },
26
+ ceiling: { name: 'UKIT_HOOK_CHAIN_CEILING_MS', fallback: 16000, min: 500, max: 120000 },
27
+ };
28
+
29
+ // Margin the outer timeout adds on top of the runner's own budget: enough to
30
+ // cover the runner's TERM→KILL grace (hook-process.mjs KILL_GRACE_MS) plus its
31
+ // hard-settle slack, so the runner always wins the race against its supervisor.
32
+ export const CHAIN_EXEC_MARGIN_MS = 4000;
33
+
34
+ // The largest budget the clamps above can produce: `min(max(base, n×child),
35
+ // max(ceiling, base))` is bounded by the larger of the two maxima. Derived rather
36
+ // than hardcoded so this bound cannot drift from the specs above.
37
+ export const MAX_CHAIN_BUDGET_MS = Math.max(
38
+ CHAIN_BUDGET_SPECS.base.max,
39
+ CHAIN_BUDGET_SPECS.ceiling.max,
40
+ );
41
+
42
+ /**
43
+ * Read one budget env knob, clamped. A missing, non-finite, or non-positive value
44
+ * falls back to the default; anything else is clamped into [min, max] so a typo
45
+ * (or a hostile value) can never disable the deadline.
46
+ */
47
+ export function envBudgetMs(spec, env = process.env) {
48
+ const raw = Number(env?.[spec.name]);
49
+ if (!Number.isFinite(raw) || raw <= 0) return spec.fallback;
50
+ return Math.min(spec.max, Math.max(spec.min, raw));
51
+ }
52
+
53
+ export function resolveChainBaseBudgetMs(env = process.env) {
54
+ return envBudgetMs(CHAIN_BUDGET_SPECS.base, env);
55
+ }
56
+
57
+ export function resolveChainChildBudgetMs(env = process.env) {
58
+ return envBudgetMs(CHAIN_BUDGET_SPECS.child, env);
59
+ }
60
+
61
+ export function resolveChainCeilingMs(env = process.env) {
62
+ return envBudgetMs(CHAIN_BUDGET_SPECS.ceiling, env);
63
+ }
64
+
65
+ /**
66
+ * The chain's total budget for `scriptCount` scripts. The floor guarantees every
67
+ * child can run its full per-child budget; the ceiling is the explicit
68
+ * critical-path cap that stops the old linear growth (n × 4s reached 24s for six
69
+ * hooks) from silently inflating PreToolUse latency.
70
+ */
71
+ export function resolveChainBudgetMs(scriptCount, env = process.env) {
72
+ const count = Number.isFinite(scriptCount) && scriptCount > 0 ? scriptCount : 1;
73
+ const base = resolveChainBaseBudgetMs(env);
74
+ const ceiling = resolveChainCeilingMs(env);
75
+ return Math.min(
76
+ Math.max(base, count * resolveChainChildBudgetMs(env)),
77
+ Math.max(ceiling, base),
78
+ );
79
+ }
80
+
81
+ /**
82
+ * The outer `pi.exec` timeout for a chain of `scriptCount` scripts. Always
83
+ * strictly larger than resolveChainBudgetMs() for the same chain shape, so the
84
+ * bridge never kills a healthy runner mid-chain, and always bounded, so the
85
+ * bridge can never hang without a deadline.
86
+ */
87
+ export function resolveChainExecTimeoutMs(scriptCount, env = process.env) {
88
+ return Math.min(
89
+ MAX_CHAIN_BUDGET_MS + CHAIN_EXEC_MARGIN_MS,
90
+ resolveChainBudgetMs(scriptCount, env) + CHAIN_EXEC_MARGIN_MS,
91
+ );
92
+ }
@@ -2,7 +2,21 @@
2
2
 
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
- import { spawnSync } from 'node:child_process';
5
+ // TASK-016: children run through the process-tree runner instead of bare
6
+ // spawnSync — a timed-out child is TERM→KILLed as a whole POSIX process group,
7
+ // so a bash wrapper can no longer orphan the Node grandchildren it spawned.
8
+ import { runHookProcess } from './hook-process.mjs';
9
+ // TASK-019: chain rows and direct-hook rows share one versioned schema and one
10
+ // append API (hook-telemetry.mjs) — including safeName for session files.
11
+ import { appendTelemetryRow, TELEMETRY_VERSION } from './hook-telemetry.mjs';
12
+ // TASK-018 review fix round 1: the chain budget is resolved by ONE shared module
13
+ // so the runner's inner deadline and the bridge's outer pi.exec timeout can never
14
+ // disagree. Kept as a per-run resolution (not module constants) so an operator's
15
+ // env change is honored without a reimport.
16
+ import {
17
+ resolveChainBudgetMs,
18
+ resolveChainChildBudgetMs,
19
+ } from './hook-chain-budget.mjs';
6
20
 
7
21
  const FAIL_CLOSED_SCRIPTS = new Set([
8
22
  'protect-files.sh',
@@ -12,26 +26,40 @@ const FAIL_CLOSED_SCRIPTS = new Set([
12
26
  'block-dangerous.sh',
13
27
  ]);
14
28
 
15
- const TOTAL_BUDGET_MS = 10000;
16
- const CHILD_BUDGET_MS = 4000;
17
29
  const MAX_BUFFER_BYTES = 2 * 1024 * 1024;
18
30
 
19
- function safeName(value) {
20
- return String(value || 'unknown').replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 96) || 'unknown';
31
+ // TASK-018 failure taxonomy (chain level; distinct from hook-process.mjs's
32
+ // process-level kinds). Overflow, timeout, signal, and exit-code failures are
33
+ // distinct values so downstream consumers never have to guess:
34
+ // 'ok' — child exited 0 on its own
35
+ // 'exit-code' — child exited non-zero on its own (a verdict, possibly a block)
36
+ // 'output-overflow' — captured output exceeded maxBuffer; the tree was culled
37
+ // 'timeout' — this runner killed the child at its deadline
38
+ // 'signal' — the child died from a signal this runner did not send
39
+ // 'error' — the child never spawned (ENOENT and friends)
40
+ // 'budget-exhausted' — the script never ran: the chain total budget was spent
41
+ function chainFailureKind(processResult) {
42
+ switch (processResult?.failureKind) {
43
+ case 'overflow':
44
+ return 'output-overflow';
45
+ case 'deadline':
46
+ return 'timeout';
47
+ case 'signal':
48
+ return 'signal';
49
+ case 'error':
50
+ return 'error';
51
+ default:
52
+ return (processResult?.code ?? 1) === 0 ? 'ok' : 'exit-code';
53
+ }
21
54
  }
22
55
 
23
56
  function recordTiming(projectRoot, payload, timing) {
24
- try {
25
- const dir = path.join(projectRoot, '.ukit', 'storage', 'cache', 'hook-latency');
26
- fs.mkdirSync(dir, { recursive: true });
27
- const filePath = path.join(dir, `${safeName(payload?.session_id)}.jsonl`);
28
- fs.appendFileSync(filePath, `${JSON.stringify(timing)}\n`, 'utf8');
29
- } catch {
30
- // Timing telemetry is advisory and must never delay or block a tool call.
31
- }
57
+ // Timing telemetry is advisory and must never delay or block a tool call;
58
+ // appendTelemetryRow carries the same posture (and the per-session cap).
59
+ appendTelemetryRow(projectRoot, payload?.session_id, timing);
32
60
  }
33
61
 
34
- function run(payloadText, scriptPaths) {
62
+ async function run(payloadText, scriptPaths) {
35
63
  const payload = JSON.parse(payloadText || '{}');
36
64
  const firstScript = scriptPaths[0] || '';
37
65
  const projectRoot = firstScript
@@ -40,10 +68,14 @@ function run(payloadText, scriptPaths) {
40
68
  const startedAt = Date.now();
41
69
  // The chain budget must always be able to run EVERY child at its full per-child
42
70
  // budget — a fixed total silently starves later scripts once a chain grows
43
- // (UserPromptSubmit now carries 4 hooks). The floor keeps short chains at 10s.
44
- const totalBudgetMs = Math.max(TOTAL_BUDGET_MS, scriptPaths.length * CHILD_BUDGET_MS);
71
+ // (UserPromptSubmit now carries 4 hooks). The floor keeps short chains at 10s,
72
+ // and TASK-018's explicit ceiling stops the per-chain growth from running away.
73
+ // Resolved per run (not at import) so an env change takes effect immediately.
74
+ const childBudgetMs = resolveChainChildBudgetMs();
75
+ const totalBudgetMs = resolveChainBudgetMs(scriptPaths.length);
45
76
  const deadline = startedAt + totalBudgetMs;
46
77
  const results = [];
78
+ let budgetExhausted = false;
47
79
 
48
80
  for (const scriptPath of scriptPaths) {
49
81
  const scriptName = path.basename(scriptPath);
@@ -54,29 +86,42 @@ function run(payloadText, scriptPaths) {
54
86
  code: 1,
55
87
  stdout: '',
56
88
  stderr: `hook chain exceeded its ${totalBudgetMs}ms total budget`,
89
+ failureKind: 'budget-exhausted',
57
90
  killed: true,
58
91
  elapsedMs: 0,
59
92
  });
93
+ budgetExhausted = true;
60
94
  break;
61
95
  }
62
96
 
63
97
  const childStartedAt = Date.now();
64
- const result = spawnSync(scriptPath, [], {
65
- cwd: projectRoot,
66
- env: { ...process.env, CLAUDE_PROJECT_DIR: projectRoot },
98
+ const result = await runHookProcess({
99
+ command: scriptPath,
100
+ args: [],
67
101
  input: payloadText,
68
- encoding: 'utf8',
69
- timeout: Math.min(CHILD_BUDGET_MS, remainingMs),
102
+ deadlineMs: Math.min(childBudgetMs, remainingMs),
70
103
  maxBuffer: MAX_BUFFER_BYTES,
104
+ cwd: projectRoot,
105
+ env: { ...process.env, CLAUDE_PROJECT_DIR: projectRoot },
71
106
  });
72
- const code = Number.isFinite(result.status) ? result.status : 1;
73
- const killed = Boolean(result.signal || result.error?.code === 'ETIMEDOUT');
74
- const stderr = [result.stderr, result.error?.message].filter(Boolean).join('\n');
107
+ const failureKind = chainFailureKind(result);
108
+ const code = Number.isFinite(result.code) ? result.code : 1;
109
+ // `killed` keeps its old meaning for the bridge: the child was stopped before
110
+ // a natural exit. The DISTINCT cause lives in failureKind — an overflowing
111
+ // child is no longer reported as a generic kill or a timeout.
112
+ const killed =
113
+ failureKind === 'output-overflow' ||
114
+ failureKind === 'timeout' ||
115
+ failureKind === 'signal';
75
116
  results.push({
76
117
  scriptName,
77
118
  code,
78
- stdout: result.stdout || '',
79
- stderr,
119
+ // TASK-018: an overflowed child's capture is truncated mid-stream and may
120
+ // embed anything (secrets included) — it never leaves this process. The
121
+ // emitting hook should keep its own output bounded; this is the backstop.
122
+ stdout: failureKind === 'output-overflow' ? '' : (result.stdout || ''),
123
+ stderr: result.stderr || '',
124
+ failureKind,
80
125
  killed,
81
126
  elapsedMs: Date.now() - childStartedAt,
82
127
  });
@@ -87,22 +132,32 @@ function run(payloadText, scriptPaths) {
87
132
  }
88
133
 
89
134
  const elapsedMs = Date.now() - startedAt;
135
+ // TASK-019: versioned rows shared with direct hooks. `outcome` reuses this
136
+ // runner's own failure taxonomy — the aggregate of the worst child result —
137
+ // so consumers never re-measure or guess across the two hook paths.
138
+ const firstFailure = results.find((result) => result.failureKind !== 'ok');
90
139
  recordTiming(projectRoot, payload, {
140
+ v: TELEMETRY_VERSION,
91
141
  ts: Date.now(),
142
+ hook: 'hook-chain-runner',
143
+ elapsedMs,
144
+ outcome: budgetExhausted ? 'budget-exhausted' : (firstFailure?.failureKind ?? 'ok'),
92
145
  hookEvent: payload?.hook_event_name || null,
93
146
  toolName: payload?.tool_name || null,
94
147
  toolUseId: payload?.tool_use_id || null,
95
148
  elapsedMs,
96
149
  budgetMs: totalBudgetMs,
97
- scripts: results.map(({ scriptName, code, killed, elapsedMs: scriptElapsedMs }) => ({
150
+ budgetExhausted,
151
+ scripts: results.map(({ scriptName, code, killed, failureKind, elapsedMs: scriptElapsedMs }) => ({
98
152
  scriptName,
99
153
  code,
100
154
  killed,
155
+ failureKind,
101
156
  elapsedMs: scriptElapsedMs,
102
157
  })),
103
158
  });
104
159
 
105
- return { results, elapsedMs, budgetMs: totalBudgetMs };
160
+ return { results, elapsedMs, budgetMs: totalBudgetMs, budgetExhausted };
106
161
  }
107
162
 
108
163
  try {
@@ -118,7 +173,7 @@ try {
118
173
  payloadText = '{}';
119
174
  }
120
175
  }
121
- process.stdout.write(JSON.stringify(run(payloadText, scriptPaths)));
176
+ process.stdout.write(JSON.stringify(await run(payloadText, scriptPaths)));
122
177
  } catch (error) {
123
178
  process.stdout.write(JSON.stringify({
124
179
  results: [],
@@ -5,6 +5,29 @@
5
5
  # through ukit_stage_hook_input, then hand UKIT_INPUT_FILE (a path, never
6
6
  # the payload) to Node or read a bounded view via command substitution.
7
7
  #
8
+ # R4.5 fix (critical): staging itself is bounded. The previous version read the
9
+ # capped prefix and then drained stdin with an unconditional `cat > /dev/null`,
10
+ # so a producer that opens the pipe and never closes it held every migrated hook
11
+ # — including the fail-closed security gate — open indefinitely, and the
12
+ # security gate's deadline could not even start. Staging now:
13
+ # 1. copies at most max_bytes + 1 bytes in the BACKGROUND, and
14
+ # 2. waits for that copy with a hard staging bound (UKIT_HOOK_STAGE_MS,
15
+ # default 2000). At the bound the reader is killed, so the helper always
16
+ # returns inside the bound — a slow producer can never stall a hook. The
17
+ # default stays under the 3s hook-chain budget so even the fail-closed
18
+ # refusal returns before the harness gives up on this hook.
19
+ #
20
+ # Contract on a bounded stall (UKIT_HOOK_INPUT_STALLED=1, UKIT_INPUT_TRUNCATED=1):
21
+ # refuse -> return 2 (fail-closed; an uninspected payload must
22
+ # never be treated as a clean scan)
23
+ # truncate/temp-file -> return 0 with a short/empty staged payload, which is
24
+ # already the consumers' documented degrade path
25
+ # An empty staged file must therefore never be interpreted as "no findings"
26
+ # by a fail-closed hook — it is a refusal.
27
+ #
28
+ # The duplicating read descriptor is deliberately left OPEN for the producer:
29
+ # we stop waiting on it, we never force a write-side EPIPE.
30
+ #
8
31
  # Usage:
9
32
  # source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh"
10
33
  # trap ukit_cleanup_hook_input EXIT
@@ -20,15 +43,63 @@
20
43
 
21
44
  ukit_stage_hook_input() {
22
45
  local max_bytes="$1" policy="${2:-truncate}"
23
- local dir file size
46
+ local dir file size stage_ms stage_s reader waiter rc
47
+ stage_ms="${UKIT_HOOK_STAGE_MS:-2000}"
48
+ case "$stage_ms" in
49
+ ''|*[!0-9]*) stage_ms=2000 ;;
50
+ esac
51
+ [ "$stage_ms" -gt 0 ] 2>/dev/null || stage_ms=2000
52
+ # `sleep` counts in SECONDS, the bound is configured in milliseconds.
53
+ printf -v stage_s '%d.%03d' $((stage_ms / 1000)) $((stage_ms % 1000))
24
54
  dir="$(mktemp -d "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")" || return 1
25
55
  file="$dir/payload"
26
- head -c $((max_bytes + 1)) > "$file" 2>/dev/null
27
- # Drain the remainder so the producer never sees EPIPE; discarded bytes
28
- # are never materialized.
29
- cat > /dev/null 2>&1 || true
56
+ # Dedicated read descriptor: the background reader must not race with the
57
+ # wrapper's own stdin consumers, and it must stay open for the producer. With
58
+ # no stdin at all (invoked with fd 0 closed) the dup fails; fall back to the
59
+ # reader's own stdin, which then reads an empty payload — the documented
60
+ # "nothing to scan" posture, never a stall.
61
+ # (the redirection on `exec` itself keeps its "Bad file descriptor" off stderr)
62
+ if [ -e /dev/fd/0 ]; then
63
+ exec 8<&0
64
+ head -c $((max_bytes + 1)) <&8 > "$file" 2>/dev/null &
65
+ reader=$!
66
+ else
67
+ head -c $((max_bytes + 1)) > "$file" 2>/dev/null &
68
+ reader=$!
69
+ fi
70
+ # Watchdog in a subshell: `wait` on a non-child pid only errors, so this is
71
+ # the portable way to bound the reader on bash 3.2 (macOS). Its own stdio is
72
+ # redirected to the null device so it can never hold the hook's output pipes
73
+ # open (which would stall the harness's close-after-earliest-eof contract).
74
+ ( sleep "$stage_s" 2>/dev/null; kill -9 "$reader" 2>/dev/null ) <&- >/dev/null 2>&1 &
75
+ waiter=$!
76
+ wait "$reader" 2>/dev/null
77
+ rc=$?
78
+ kill "$waiter" 2>/dev/null
79
+ wait "$waiter" 2>/dev/null
80
+ UKIT_HOOK_INPUT_STALLED=0
81
+ if [ "$rc" -ne 0 ]; then
82
+ UKIT_HOOK_INPUT_STALLED=1
83
+ if [ "$policy" = "refuse" ]; then
84
+ # Fail-closed: an unstaged payload was never inspected, and 1 is a
85
+ # *non-blocking* hook error — the gate would let unscannable input through.
86
+ exec 8<&-
87
+ rm -rf "$dir"
88
+ UKIT_INPUT_FILE=""
89
+ UKIT_INPUT_TRUNCATED=0
90
+ return 2
91
+ fi
92
+ fi
93
+ # Stop waiting on a producer that may hold the pipe open forever, and drop our
94
+ # read descriptor while the producer keeps its own write end (never an EPIPE).
95
+ exec 8<&-
30
96
  size="$(wc -c < "$file" | tr -d '[:space:]')"
97
+ # `rc != 0` here means the staged read was cut off by the staging bound before
98
+ # EOF: completeness cannot be proven, so the payload is flagged truncated even
99
+ # when the bytes that did arrive are under the cap. Consumers already degrade
100
+ # on a short payload; `refuse` never reaches this point.
31
101
  UKIT_INPUT_TRUNCATED=0
102
+ [ "$rc" -ne 0 ] && UKIT_INPUT_TRUNCATED=1
32
103
  if [ "$size" -gt "$max_bytes" ]; then
33
104
  case "$policy" in
34
105
  refuse)
@@ -51,10 +122,19 @@ ukit_stage_hook_input() {
51
122
  }
52
123
 
53
124
  ukit_cleanup_hook_input() {
125
+ # TASK-019: emit the telemetry finish marker while the staged payload file
126
+ # still exists (its mtime is the envelope start). Strictly advisory — the
127
+ # marker never touches the hook's exit status, stdout, or stderr. The hook's
128
+ # own exit status is captured BEFORE this function's conditionals reset $?.
129
+ local __ukit_tel_rc=$?
130
+ if [ "${UKIT_TEL_ARMED:-}" = "1" ] && command -v ukit_hook_telemetry_finish >/dev/null 2>&1; then
131
+ UKIT_TEL_RC="$__ukit_tel_rc" ukit_hook_telemetry_finish
132
+ fi
54
133
  if [ -n "${UKIT_INPUT_FILE:-}" ] && [ -f "$UKIT_INPUT_FILE" ]; then
55
134
  rm -f "$UKIT_INPUT_FILE"
56
135
  rmdir "$(dirname "$UKIT_INPUT_FILE")" 2>/dev/null || true
57
136
  fi
58
137
  UKIT_INPUT_FILE=""
59
138
  UKIT_INPUT_TRUNCATED=0
139
+ UKIT_HOOK_INPUT_STALLED=0
60
140
  }
@@ -0,0 +1,160 @@
1
+ // hook-payload-store.mjs — atomic payload lifecycle for hook chains (TASK-031, H24).
2
+ //
3
+ // The OMP bridge used to stage EVERY chain payload through a temp file and pay a
4
+ // synchronous stale-file sweep (readdir + statSync per entry) before each exec —
5
+ // unbounded filesystem work on the critical path, outside the runner's timeout
6
+ // scope. This module keeps `@temp-file` transport (macOS ARG_MAX protection for
7
+ // PostToolUse payloads that embed whole tool outputs) but makes the request path
8
+ // O(1):
9
+ // - payloads at or under PAYLOAD_INLINE_MAX_BYTES never touch the filesystem;
10
+ // - over-cap payloads are written once, atomically (tmp + rename), owner-only;
11
+ // - stale sweeps are sampled (default 1/16 of file-mode chains), bounded
12
+ // (<= maxEntries entries of stat/rm work), and deferred off the request path
13
+ // by the caller;
14
+ // - probePayloadIntegrity lets the bridge verify, after a chain returns, that
15
+ // the file it staged is what the chain actually read.
16
+ // Readers (hook-chain-runner.mjs) keep accepting the `@path` argv form unchanged.
17
+
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import crypto from 'node:crypto';
21
+
22
+ // Comfortably under both macOS limits (256KB per argv entry, ~1MB total ARG_MAX
23
+ // including environment), so a chain never fails exec before any hook runs.
24
+ export const PAYLOAD_INLINE_MAX_BYTES = 64 * 1024;
25
+
26
+ const SWEEP_PROBABILITY_DEFAULT = 1 / 16;
27
+ const SWEEP_MAX_ENTRIES = 128;
28
+ const SWEEP_MAX_AGE_MS = 60 * 60 * 1000;
29
+
30
+ function sweepProbabilityFromEnv() {
31
+ const raw = Number(process.env.UKIT_HOOK_SWEEP_PROBABILITY);
32
+ if (!Number.isFinite(raw)) return SWEEP_PROBABILITY_DEFAULT;
33
+ return Math.min(1, Math.max(0, raw));
34
+ }
35
+
36
+ function inlineReference(payloadText, bytes) {
37
+ return {
38
+ mode: 'inline',
39
+ arg: payloadText,
40
+ text: payloadText,
41
+ path: null,
42
+ bytes,
43
+ cleanup() { /* nothing was staged — O(1) by construction */ },
44
+ };
45
+ }
46
+
47
+ // createPayloadReference(text, {maxBytes, deadlineMs, dir}) ->
48
+ // { mode: 'inline'|'file', arg, text, path, bytes, cleanup() }
49
+ //
50
+ // `arg` is what the bridge passes to the runner: the payload text itself for
51
+ // inline mode (raw JSON never starts with '@', so the two forms stay
52
+ // unambiguous), or `@<safe-temp-path>` for file mode. `cleanup()` removes a
53
+ // staged file exactly once and is a no-op for inline mode. Any staging failure
54
+ // (unwritable root, deadline exhausted) degrades to inline transport — the
55
+ // bridge owns the policy for what an oversized argv then costs at exec.
56
+ export function createPayloadReference(text, {
57
+ maxBytes = PAYLOAD_INLINE_MAX_BYTES,
58
+ deadlineMs = 250,
59
+ dir,
60
+ } = {}) {
61
+ const payloadText = String(text ?? '');
62
+ const bytes = Buffer.byteLength(payloadText, 'utf8');
63
+ if (bytes <= maxBytes) return inlineReference(payloadText, bytes);
64
+ try {
65
+ const startedAt = Date.now();
66
+ if (!(deadlineMs > 0) || Date.now() - startedAt > deadlineMs) {
67
+ throw new Error('payload staging deadline exhausted');
68
+ }
69
+ if (!dir) throw new Error('payload staging dir is required for over-cap payloads');
70
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
71
+ const name = `${Date.now().toString(36)}-${process.pid}-${crypto.randomBytes(6).toString('hex')}.json`;
72
+ const finalPath = path.join(dir, name);
73
+ const tmpPath = path.join(dir, `.${name}.tmp`);
74
+ // Atomic lifecycle: readers either see the complete previous state or the
75
+ // complete new file — never a partial write mid-stream.
76
+ fs.writeFileSync(tmpPath, payloadText, { encoding: 'utf8', mode: 0o600 });
77
+ fs.renameSync(tmpPath, finalPath);
78
+ return {
79
+ mode: 'file',
80
+ arg: `@${finalPath}`,
81
+ text: payloadText,
82
+ path: finalPath,
83
+ bytes,
84
+ cleanup() {
85
+ try { fs.rmSync(finalPath, { force: true }); } catch { /* best effort */ }
86
+ },
87
+ };
88
+ } catch {
89
+ return inlineReference(payloadText, bytes);
90
+ }
91
+ }
92
+
93
+ // probePayloadIntegrity(reference) -> null | 'missing' | 'partial'
94
+ //
95
+ // O(1): one stat of the file this bridge staged. Called by the bridge after the
96
+ // chain returns and before cleanup — if the file vanished or changed size
97
+ // mid-flight, the scripts ran against something other than the host's payload
98
+ // and their verdicts are void.
99
+ export function probePayloadIntegrity(reference) {
100
+ if (!reference || reference.mode !== 'file' || !reference.path) return null;
101
+ try {
102
+ const stats = fs.statSync(reference.path);
103
+ return stats.size === reference.bytes ? null : 'partial';
104
+ } catch {
105
+ return 'missing';
106
+ }
107
+ }
108
+
109
+ // sweepStalePayloads(dir, {now, maxAgeMs, maxEntries}) -> {sampled, scanned, removed}
110
+ //
111
+ // Bounded: processes at most maxEntries directory entries regardless of how many
112
+ // stale files accumulated (the crash-after-abandon scenario), so even an
113
+ // unsampled sweep cannot stall a session on a huge directory. Fresh files and
114
+ // non-payload entries are never removed.
115
+ export function sweepStalePayloads(dir, {
116
+ now = Date.now,
117
+ maxAgeMs = SWEEP_MAX_AGE_MS,
118
+ maxEntries = SWEEP_MAX_ENTRIES,
119
+ } = {}) {
120
+ let names;
121
+ try {
122
+ names = fs.readdirSync(dir);
123
+ } catch {
124
+ return { sampled: true, scanned: 0, removed: 0 };
125
+ }
126
+ const cutoff = now() - maxAgeMs;
127
+ let scanned = 0;
128
+ let removed = 0;
129
+ for (const name of names) {
130
+ if (scanned >= maxEntries) break;
131
+ scanned += 1;
132
+ if (!name.endsWith('.json') && !name.endsWith('.tmp')) continue;
133
+ const filePath = path.join(dir, name);
134
+ try {
135
+ if (fs.statSync(filePath).mtimeMs < cutoff) {
136
+ fs.rmSync(filePath, { force: true });
137
+ removed += 1;
138
+ }
139
+ } catch { /* raced away — fine */ }
140
+ }
141
+ return { sampled: true, scanned, removed };
142
+ }
143
+
144
+ // maybeSweepStalePayloads(dir, {probability, random, ...}) -> sweep report
145
+ //
146
+ // Sampled gate: by default only ~1 in 16 file-mode chain requests pays for a
147
+ // (bounded) sweep at all. Probability comes from the caller, or from
148
+ // UKIT_HOOK_SWEEP_PROBABILITY (clamped to [0,1]) so operators and tests can
149
+ // disable or force sweeping without editing code.
150
+ export function maybeSweepStalePayloads(dir, {
151
+ probability,
152
+ random = Math.random,
153
+ now,
154
+ maxAgeMs,
155
+ maxEntries,
156
+ } = {}) {
157
+ const p = Number.isFinite(probability) ? Math.min(1, Math.max(0, probability)) : sweepProbabilityFromEnv();
158
+ if (random() >= p) return { sampled: false, scanned: 0, removed: 0 };
159
+ return sweepStalePayloads(dir, { now, maxAgeMs, maxEntries });
160
+ }