@ngockhoale/ukit 2.6.11 → 2.7.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.
@@ -57,8 +57,8 @@
57
57
  "hooks": [
58
58
  {
59
59
  "type": "command",
60
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/sensitive-data-guard.sh\"",
61
- "timeout": 8
60
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/sensitive-data-guard.sh\":8",
61
+ "timeout": 18
62
62
  }
63
63
  ]
64
64
  },
@@ -67,33 +67,8 @@
67
67
  "hooks": [
68
68
  {
69
69
  "type": "command",
70
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/protect-files.sh\"",
71
- "timeout": 8
72
- },
73
- {
74
- "type": "command",
75
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/stale-spec-guard.sh\"",
76
- "timeout": 11
77
- },
78
- {
79
- "type": "command",
80
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/pre-edit-backup.sh\"",
81
- "timeout": 8
82
- },
83
- {
84
- "type": "command",
85
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/skill-router.sh\"",
86
- "timeout": 20
87
- },
88
- {
89
- "type": "command",
90
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/handoff-model-guard.sh\"",
91
- "timeout": 8
92
- },
93
- {
94
- "type": "command",
95
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/context-hardcap-gate.sh\"",
96
- "timeout": 8
70
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/protect-files.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/stale-spec-guard.sh\":11 \"$CLAUDE_PROJECT_DIR/.claude/hooks/pre-edit-backup.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/skill-router.sh\":20 \"$CLAUDE_PROJECT_DIR/.claude/hooks/handoff-model-guard.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/context-hardcap-gate.sh\":8",
71
+ "timeout": 73
97
72
  }
98
73
  ]
99
74
  },
@@ -102,33 +77,8 @@
102
77
  "hooks": [
103
78
  {
104
79
  "type": "command",
105
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/auto-allow-bash.sh\"",
106
- "timeout": 15
107
- },
108
- {
109
- "type": "command",
110
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/block-dangerous.sh\"",
111
- "timeout": 8
112
- },
113
- {
114
- "type": "command",
115
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/sensitive-data-guard.sh\"",
116
- "timeout": 8
117
- },
118
- {
119
- "type": "command",
120
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/handoff-model-guard.sh\"",
121
- "timeout": 8
122
- },
123
- {
124
- "type": "command",
125
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/context-hardcap-gate.sh\"",
126
- "timeout": 8
127
- },
128
- {
129
- "type": "command",
130
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/verification-guard.sh\"",
131
- "timeout": 8
80
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/auto-allow-bash.sh\":15 \"$CLAUDE_PROJECT_DIR/.claude/hooks/block-dangerous.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/sensitive-data-guard.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/handoff-model-guard.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/context-hardcap-gate.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/verification-guard.sh\":8",
81
+ "timeout": 65
132
82
  }
133
83
  ]
134
84
  }
@@ -139,8 +89,8 @@
139
89
  "hooks": [
140
90
  {
141
91
  "type": "command",
142
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/record-execution.sh\"",
143
- "timeout": 8
92
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/record-execution.sh\":8",
93
+ "timeout": 18
144
94
  }
145
95
  ]
146
96
  },
@@ -149,18 +99,8 @@
149
99
  "hooks": [
150
100
  {
151
101
  "type": "command",
152
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/post-edit-verify.sh\"",
153
- "timeout": 8
154
- },
155
- {
156
- "type": "command",
157
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/record-execution.sh\"",
158
- "timeout": 8
159
- },
160
- {
161
- "type": "command",
162
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/task-watchdog.sh\"",
163
- "timeout": 8
102
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/post-edit-verify.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/record-execution.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/task-watchdog.sh\":8",
103
+ "timeout": 34
164
104
  }
165
105
  ]
166
106
  },
@@ -169,13 +109,8 @@
169
109
  "hooks": [
170
110
  {
171
111
  "type": "command",
172
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/compress-output.sh\"",
173
- "timeout": 12
174
- },
175
- {
176
- "type": "command",
177
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/record-execution.sh\"",
178
- "timeout": 8
112
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/compress-output.sh\":12 \"$CLAUDE_PROJECT_DIR/.claude/hooks/record-execution.sh\":8",
113
+ "timeout": 30
179
114
  }
180
115
  ]
181
116
  }
@@ -185,23 +120,8 @@
185
120
  "hooks": [
186
121
  {
187
122
  "type": "command",
188
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/sensitive-data-guard.sh\"",
189
- "timeout": 8
190
- },
191
- {
192
- "type": "command",
193
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/skill-router.sh\"",
194
- "timeout": 20
195
- },
196
- {
197
- "type": "command",
198
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/vision-router.sh\"",
199
- "timeout": 12
200
- },
201
- {
202
- "type": "command",
203
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/context-window-guard.sh\"",
204
- "timeout": 10
123
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/sensitive-data-guard.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/skill-router.sh\":20 \"$CLAUDE_PROJECT_DIR/.claude/hooks/vision-router.sh\":12 \"$CLAUDE_PROJECT_DIR/.claude/hooks/context-window-guard.sh\":10",
124
+ "timeout": 60
205
125
  }
206
126
  ]
207
127
  }
@@ -211,8 +131,8 @@
211
131
  "hooks": [
212
132
  {
213
133
  "type": "command",
214
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/completion-gate.sh\"",
215
- "timeout": 8
134
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/completion-gate.sh\":8",
135
+ "timeout": 18
216
136
  }
217
137
  ]
218
138
  }
@@ -222,8 +142,8 @@
222
142
  "hooks": [
223
143
  {
224
144
  "type": "command",
225
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/reinject-context.sh\"",
226
- "timeout": 8
145
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/reinject-context.sh\":8",
146
+ "timeout": 18
227
147
  }
228
148
  ]
229
149
  }
@@ -233,23 +153,8 @@
233
153
  "hooks": [
234
154
  {
235
155
  "type": "command",
236
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/project-important.sh\"",
237
- "timeout": 8
238
- },
239
- {
240
- "type": "command",
241
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/auto-prune-bash.sh\"",
242
- "timeout": 12
243
- },
244
- {
245
- "type": "command",
246
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/reset-compact-pressure.sh\"",
247
- "timeout": 12
248
- },
249
- {
250
- "type": "command",
251
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/handoff-resume.sh\"",
252
- "timeout": 8
156
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/project-important.sh\":8 \"$CLAUDE_PROJECT_DIR/.claude/hooks/auto-prune-bash.sh\":12 \"$CLAUDE_PROJECT_DIR/.claude/hooks/reset-compact-pressure.sh\":12 \"$CLAUDE_PROJECT_DIR/.claude/hooks/handoff-resume.sh\":8",
157
+ "timeout": 50
253
158
  }
254
159
  ]
255
160
  }
@@ -259,8 +164,8 @@
259
164
  "hooks": [
260
165
  {
261
166
  "type": "command",
262
- "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/session-episode.sh\"",
263
- "timeout": 8
167
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/ukit/runtime/hook-chain-runner.mjs\" --emit-verdict - \"$CLAUDE_PROJECT_DIR/.claude/hooks/session-episode.sh\":8",
168
+ "timeout": 18
264
169
  }
265
170
  ]
266
171
  }
@@ -14,7 +14,9 @@ import { appendTelemetryRow, TELEMETRY_VERSION } from './hook-telemetry.mjs';
14
14
  // disagree. Kept as a per-run resolution (not module constants) so an operator's
15
15
  // env change is honored without a reimport.
16
16
  import {
17
+ resolveChainBaseBudgetMs,
17
18
  resolveChainBudgetMs,
19
+ resolveChainCeilingMs,
18
20
  resolveChainChildBudgetMs,
19
21
  } from './hook-chain-budget.mjs';
20
22
 
@@ -28,6 +30,41 @@ const FAIL_CLOSED_SCRIPTS = new Set([
28
30
 
29
31
  const MAX_BUFFER_BYTES = 2 * 1024 * 1024;
30
32
 
33
+ // TASK-234 review fix (critical): stdin staging must be bounded. The old
34
+ // `fs.readFileSync(0)` blocked until the producer closed the pipe — a stalled
35
+ // producer held the runner until the settings timeout SIGKILLed it, so every
36
+ // fail-closed gate in the chain silently failed OPEN (no verdict emitted).
37
+ // Bound the read: at most MAX_STDIN_BYTES and at most STDIN_STAGE_MS, then
38
+ // mark the payload degraded so children see the same truncated-input contract
39
+ // hook-input.sh gives them (UKIT_INPUT_TRUNCATED=1 → fail-closed gates deny).
40
+ const MAX_STDIN_BYTES = 2 * 1024 * 1024;
41
+ const STDIN_STAGE_MS = Number(process.env.UKIT_HOOK_STDIN_STAGE_MS || 2000);
42
+
43
+ async function readStdinBounded() {
44
+ return new Promise((resolve) => {
45
+ const chunks = [];
46
+ let bytes = 0;
47
+ let settled = false;
48
+ const finish = (truncated) => {
49
+ if (settled) return;
50
+ settled = true;
51
+ clearTimeout(timer);
52
+ process.stdin.removeAllListeners();
53
+ process.stdin.unref?.();
54
+ resolve({ text: Buffer.concat(chunks).toString('utf8'), truncated });
55
+ };
56
+ const timer = setTimeout(() => finish(true), STDIN_STAGE_MS);
57
+ process.stdin.on('data', (chunk) => {
58
+ chunks.push(chunk);
59
+ bytes += chunk.length;
60
+ if (bytes > MAX_STDIN_BYTES) finish(true);
61
+ });
62
+ process.stdin.on('end', () => finish(false));
63
+ process.stdin.on('error', () => finish(true));
64
+ process.stdin.resume();
65
+ });
66
+ }
67
+
31
68
  // TASK-018 failure taxonomy (chain level; distinct from hook-process.mjs's
32
69
  // process-level kinds). Overflow, timeout, signal, and exit-code failures are
33
70
  // distinct values so downstream consumers never have to guess:
@@ -58,8 +95,24 @@ function recordTiming(projectRoot, payload, timing) {
58
95
  // appendTelemetryRow carries the same posture (and the per-session cap).
59
96
  appendTelemetryRow(projectRoot, payload?.session_id, timing);
60
97
  }
98
+ // TASK-234: a script arg may carry a per-script timeout suffix `<path>:<seconds>`
99
+ // so consolidated settings.json chains keep each hook's original settings.json
100
+ // `timeout` instead of sharing one child budget. The suffix is stripped before
101
+ // the path is used; absent → resolveChainChildBudgetMs() fallback, unchanged.
102
+ // A `:0` or negative suffix is rejected (falls back) — zero would mean "no
103
+ // budget", which silently disables the deadline; that is never a valid hook
104
+ // contract.
105
+ function parseScriptArg(arg) {
106
+ const match = /^(.*):(\d+(?:\.\d+)?)$/.exec(arg || '');
107
+ if (!match) return { scriptPath: arg, timeoutMs: null };
108
+ const seconds = Number(match[2]);
109
+ if (!Number.isFinite(seconds) || seconds <= 0) return { scriptPath: arg, timeoutMs: null };
110
+ return { scriptPath: match[1], timeoutMs: Math.round(seconds * 1000) };
111
+ }
61
112
 
62
- async function run(payloadText, scriptPaths) {
113
+ async function run(payloadText, scriptArgs, { chainMarker = true } = {}) {
114
+ const parsedArgs = scriptArgs.map(parseScriptArg);
115
+ const scriptPaths = parsedArgs.map((a) => a.scriptPath);
63
116
  const payload = JSON.parse(payloadText || '{}');
64
117
  const firstScript = scriptPaths[0] || '';
65
118
  const projectRoot = firstScript
@@ -71,13 +124,41 @@ async function run(payloadText, scriptPaths) {
71
124
  // (UserPromptSubmit now carries 4 hooks). The floor keeps short chains at 10s,
72
125
  // and TASK-018's explicit ceiling stops the per-chain growth from running away.
73
126
  // Resolved per run (not at import) so an env change takes effect immediately.
74
- const childBudgetMs = resolveChainChildBudgetMs();
75
- const totalBudgetMs = resolveChainBudgetMs(scriptPaths.length);
127
+ const defaultChildBudgetMs = resolveChainChildBudgetMs();
128
+ const childBudgetsMs = parsedArgs.map((a) => a.timeoutMs ?? defaultChildBudgetMs);
129
+ // TASK-234: with per-script budgets the total must cover the SUM of declared
130
+ // budgets (not count × shared fallback) or a 6-hook Edit chain would be capped
131
+ // below its own scripts' combined timeouts. When EVERY script declares a
132
+ // budget the declared sum IS the inner budget — the count×fallback floor
133
+ // would otherwise exceed the registered outer timeout (PostToolUse Edit|Write:
134
+ // 3×12s=36s inner vs 34s outer → host kills the runner mid-chain). Mixed
135
+ // chains keep the floor so undeclared scripts still get the fallback.
136
+ const declaredTotalMs = childBudgetsMs.reduce((sum, ms) => sum + ms, 0);
137
+ const allDeclared = parsedArgs.every((a) => a.timeoutMs != null);
138
+ // TASK-234 review fix (important): the ceiling bounds FALLBACK-driven growth,
139
+ // never the declared contract — a 52s ceiling clamping a 63s declared sum
140
+ // starves the last fail-closed gate (context-hardcap-gate) and turns a legal
141
+ // edit into a block. Only EXPLICIT `:N` budgets raise the ceiling floor —
142
+ // fallback-filled budgets must not, or bare bridge chains would bypass the
143
+ // ceiling entirely (ompHookBridge TASK-018 pins that bound).
144
+ const explicitDeclaredMs = parsedArgs.reduce((sum, a) => sum + (a.timeoutMs ?? 0), 0);
145
+ const ceilingMs = Math.max(
146
+ resolveChainCeilingMs(),
147
+ resolveChainBaseBudgetMs(),
148
+ explicitDeclaredMs,
149
+ );
150
+ const totalBudgetMs = Math.min(
151
+ allDeclared
152
+ ? declaredTotalMs
153
+ : Math.max(resolveChainBudgetMs(scriptPaths.length), declaredTotalMs),
154
+ ceilingMs,
155
+ );
76
156
  const deadline = startedAt + totalBudgetMs;
77
157
  const results = [];
78
158
  let budgetExhausted = false;
79
159
 
80
- for (const scriptPath of scriptPaths) {
160
+ for (let scriptIndex = 0; scriptIndex < scriptPaths.length; scriptIndex++) {
161
+ const scriptPath = scriptPaths[scriptIndex];
81
162
  const scriptName = path.basename(scriptPath);
82
163
  const remainingMs = deadline - Date.now();
83
164
  if (remainingMs <= 0) {
@@ -97,9 +178,9 @@ async function run(payloadText, scriptPaths) {
97
178
  const childStartedAt = Date.now();
98
179
  const result = await runHookProcess({
99
180
  command: scriptPath,
181
+ deadlineMs: Math.min(childBudgetsMs[scriptIndex], remainingMs),
100
182
  args: [],
101
183
  input: payloadText,
102
- deadlineMs: Math.min(childBudgetMs, remainingMs),
103
184
  maxBuffer: MAX_BUFFER_BYTES,
104
185
  cwd: projectRoot,
105
186
  // TASK-223 (HK-401): mark chain-spawned children so their structured
@@ -109,7 +190,10 @@ async function run(payloadText, scriptPaths) {
109
190
  env: {
110
191
  ...process.env,
111
192
  CLAUDE_PROJECT_DIR: projectRoot,
112
- UKIT_HOOK_CHAIN_RUNNER: '1',
193
+ // TASK-234: the marker selects the omp structured-decision contract
194
+ // (ask + exit 2). Under --emit-verdict the runner replays the direct
195
+ // Claude contract (deny + exit 0), so children must NOT see it.
196
+ ...(chainMarker ? { UKIT_HOOK_CHAIN_RUNNER: '1' } : {}),
113
197
  },
114
198
  });
115
199
  const failureKind = chainFailureKind(result);
@@ -139,6 +223,15 @@ async function run(payloadText, scriptPaths) {
139
223
  }
140
224
  }
141
225
 
226
+ // TASK-234 review fix (critical): a mid-chain break (killed advisory, budget
227
+ // exhausted, or a fail-closed non-zero) leaves later scripts unrun. When any
228
+ // of those unrun scripts is fail-closed, the chain must fail CLOSED — the old
229
+ // per-script path ran every hook independently, so a timed-out advisory never
230
+ // skipped a gate. `skippedFailClosed` carries that signal to the verdict.
231
+ const skippedFailClosed = scriptPaths
232
+ .slice(results.length)
233
+ .some((p) => FAIL_CLOSED_SCRIPTS.has(path.basename(p)));
234
+
142
235
  const elapsedMs = Date.now() - startedAt;
143
236
  // TASK-019: versioned rows shared with direct hooks. `outcome` reuses this
144
237
  // runner's own failure taxonomy — the aggregate of the worst child result —
@@ -165,23 +258,137 @@ async function run(payloadText, scriptPaths) {
165
258
  })),
166
259
  });
167
260
 
168
- return { results, elapsedMs, budgetMs: totalBudgetMs, budgetExhausted };
261
+ return { results, elapsedMs, budgetMs: totalBudgetMs, budgetExhausted, skippedFailClosed };
169
262
  }
170
263
 
171
264
  try {
172
- const [, , payloadArg = '{}', ...scriptPaths] = process.argv;
265
+ let argv = process.argv.slice(2);
266
+ // TASK-234: `--emit-verdict` adapts the runner for Claude Code settings.json
267
+ // hooks, where the command's own stdout/exit-code IS the verdict — not the
268
+ // JSON aggregate the omp bridge parses. In this mode the runner replays the
269
+ // last executed script's stdout verbatim and maps the chain outcome onto the
270
+ // single-command contract: exit 0 normally; exit 2 + the child's stderr when
271
+ // a child blocked (code 2) or a FAIL_CLOSED script could not produce a
272
+ // verdict (killed/error/budget-exhausted). Non-fail-closed transport failures
273
+ // stay fail-open (exit 0), matching today's per-script behavior where a
274
+ // timed-out advisory hook never blocks the call.
275
+ const emitVerdict = argv[0] === '--emit-verdict';
276
+ if (emitVerdict) argv = argv.slice(1);
277
+ const [payloadArg = '{}', ...scriptPaths] = argv;
173
278
  // The bridge passes the payload as a temp file (`@path`) when it can: argv is capped
174
279
  // (~256KB per arg on macOS) and PostToolUse Bash payloads embed whole tool outputs.
175
280
  // A leading '@' cannot occur in raw JSON, so the two forms are unambiguous.
281
+ // '-' reads the payload from stdin — the form Claude Code hook commands use.
176
282
  let payloadText = payloadArg;
177
- if (payloadArg.startsWith('@')) {
283
+ let stdinTruncated = false;
284
+ if (payloadArg === '-') {
285
+ const staged = await readStdinBounded();
286
+ payloadText = staged.text;
287
+ stdinTruncated = staged.truncated;
288
+ } else if (payloadArg.startsWith('@')) {
178
289
  try {
179
290
  payloadText = fs.readFileSync(payloadArg.slice(1), 'utf8');
180
291
  } catch {
181
292
  payloadText = '{}';
182
293
  }
183
294
  }
184
- process.stdout.write(JSON.stringify(await run(payloadText, scriptPaths)));
295
+ // A truncated stdin means the payload was never fully read — the same
296
+ // degraded contract hook-input.sh enforces (uninspected input must never be
297
+ // treated as a clean scan). Children stage their own stdin and cannot see
298
+ // the runner's truncation, so the runner owns the degraded verdict: when the
299
+ // chain carries a fail-closed gate, emit deny now instead of letting gates
300
+ // pass on a payload they never fully received.
301
+ if (stdinTruncated) {
302
+ const hasFailClosed = scriptPaths.some((p) =>
303
+ FAIL_CLOSED_SCRIPTS.has(path.basename(String(p).replace(/:\d+(?:\.\d+)?$/, ''))));
304
+ if (hasFailClosed) {
305
+ const deny = JSON.stringify({
306
+ hookSpecificOutput: {
307
+ hookEventName: 'PreToolUse',
308
+ permissionDecision: 'deny',
309
+ permissionDecisionReason:
310
+ 'UKit could not fully read this tool-call payload (stdin staging was cut at the deadline/size bound), so the fail-closed gate chain cannot prove it safe.',
311
+ },
312
+ });
313
+ if (emitVerdict) {
314
+ process.stdout.write(deny);
315
+ process.exitCode = 0;
316
+ } else {
317
+ process.stdout.write(JSON.stringify({
318
+ results: [],
319
+ wrapperError: 'stdin staging truncated — fail-closed chain refused',
320
+ stdinTruncated: true,
321
+ }));
322
+ process.exitCode = 2;
323
+ }
324
+ process.exit(emitVerdict ? 0 : 2);
325
+ }
326
+ }
327
+ const chain = await run(payloadText, scriptPaths, { chainMarker: !emitVerdict });
328
+ if (!emitVerdict) {
329
+ process.stdout.write(JSON.stringify(chain));
330
+ } else {
331
+ // TASK-234: a script that emits a hookSpecificOutput decision JSON owns the
332
+ // verdict even when it exits 0 (the direct Claude contract: deny + exit 0
333
+ // still blocks). The first decision wins, matching per-script semantics
334
+ // where each hook's output is its own verdict and a deny short-circuits.
335
+ const decisionResult = chain.results.find((r) =>
336
+ typeof r.stdout === 'string' && r.stdout.includes('"hookSpecificOutput"'));
337
+ const last = decisionResult ?? chain.results[chain.results.length - 1];
338
+
339
+ // TASK-234 review fix (critical): context stdout must be REPLAYED, not
340
+ // dropped. SessionStart/UserPromptSubmit hooks emit plain-text context
341
+ // (PROJECT_IMPORTANT mandate, skill-router guidance) — replaying only the
342
+ // decision owner's or last script's stdout loses every earlier emission.
343
+ // Concatenate every script's non-decision stdout in order, then append the
344
+ // decision JSON last so the verdict still parses.
345
+ const contextStdout = chain.results
346
+ .filter((r) => r !== decisionResult && r !== last && typeof r.stdout === 'string' && r.stdout.length > 0)
347
+ .map((r) => r.stdout)
348
+ .join('');
349
+
350
+ // TASK-234 review fix (critical): a mid-chain break that skipped a
351
+ // fail-closed gate must fail CLOSED — the old per-script path ran every
352
+ // hook independently, so a killed advisory never skipped a gate.
353
+ if (chain.skippedFailClosed) {
354
+ if (contextStdout) process.stdout.write(contextStdout);
355
+ const skipped = scriptPaths
356
+ .slice(chain.results.length)
357
+ .map((p) => path.basename(String(p).replace(/:\d+(?:\.\d+)?$/, '')))
358
+ .filter((name) => FAIL_CLOSED_SCRIPTS.has(name))
359
+ .join(', ');
360
+ process.stderr.write(`UKit hook chain broke before fail-closed gate(s) ran: ${skipped}\n`);
361
+ process.exitCode = 2;
362
+ } else if (!last) {
363
+ // No script ran at all (empty chain or budget spent before the first
364
+ // child). With fail-closed scripts declared in the chain this must not
365
+ // fail open.
366
+ const hasFailClosed = scriptPaths.some((p) => FAIL_CLOSED_SCRIPTS.has(path.basename(String(p).replace(/:\d+(?:\.\d+)?$/, ''))));
367
+ if (hasFailClosed) {
368
+ process.stderr.write('UKit hook chain produced no verdict — fail-closed gate did not run\n');
369
+ process.exitCode = 2;
370
+ }
371
+ } else {
372
+ const lastIsFailClosed = FAIL_CLOSED_SCRIPTS.has(last.scriptName);
373
+ const lastFailedToVerdict = last.killed || last.failureKind === 'error' || last.failureKind === 'budget-exhausted';
374
+ if (last.code === 2) {
375
+ if (contextStdout) process.stdout.write(contextStdout);
376
+ if (last.stdout) process.stdout.write(last.stdout);
377
+ if (last.stderr) process.stderr.write(last.stderr);
378
+ process.exitCode = 2;
379
+ } else if (lastIsFailClosed && lastFailedToVerdict) {
380
+ if (contextStdout) process.stdout.write(contextStdout);
381
+ if (last.stderr) process.stderr.write(last.stderr);
382
+ else process.stderr.write(`UKit fail-closed hook ${last.scriptName} could not produce a verdict (${last.failureKind})\n`);
383
+ process.exitCode = 2;
384
+ } else {
385
+ if (contextStdout) process.stdout.write(contextStdout);
386
+ if (last.stdout) process.stdout.write(last.stdout);
387
+ if (last.stderr) process.stderr.write(last.stderr);
388
+ process.exitCode = last.code === 0 ? 0 : (lastIsFailClosed ? 2 : 0);
389
+ }
390
+ }
391
+ }
185
392
  } catch (error) {
186
393
  process.stdout.write(JSON.stringify({
187
394
  results: [],
@@ -169,6 +169,125 @@ ukit_emit_input_degraded() {
169
169
  exit 0
170
170
  }
171
171
 
172
+ # TASK-003 (salvage verdicts from truncated payloads): when the staged payload
173
+ # is flagged truncated/stalled, a gated hook may still recover a DECISION-RELEVANT
174
+ # field if that field's value is provably COMPLETE — the closing quote AND a
175
+ # following `,`/`}` boundary must appear before the cut. Anything else (cut
176
+ # inside the string, EOF right after the quote, malformed prefix, missing key)
177
+ # is unrecoverable: callers map that to `deny`, never `ask` (bypassPermissions
178
+ # auto-approves `ask` on the direct host = gates silently skipped).
179
+ #
180
+ # Contract:
181
+ # ukit_salvage_tool_field <json-file> <dotted-field>
182
+ # stdout: the decoded string value (single line may contain \n escapes
183
+ # decoded — the value itself is written raw)
184
+ # exit 0 — field recovered AND proven complete
185
+ # exit 3 — unrecoverable / incomplete / malformed / non-string value
186
+ # Budget: UKIT_SALVAGE_BUDGET_MS (default 3000) bounds the node process; the
187
+ # file is already capped by staging (≤2MiB), so no unbounded reads.
188
+ # Single string fields only — this is a salvage step, not a JSON repairer.
189
+ ukit_salvage_tool_field() {
190
+ UKIT_SALVAGE_FILE="$1" UKIT_SALVAGE_FIELD="$2" \
191
+ UKIT_SALVAGE_BUDGET_MS="${UKIT_SALVAGE_BUDGET_MS:-3000}" node <<'UKIT_SALVAGE_NODE'
192
+ const budget = Number.parseInt(process.env.UKIT_SALVAGE_BUDGET_MS || '3000', 10) || 3000;
193
+ setTimeout(() => process.exit(3), budget).unref();
194
+ const fs = require('fs');
195
+ const fail = () => process.exit(3);
196
+ let data;
197
+ try {
198
+ data = fs.readFileSync(process.env.UKIT_SALVAGE_FILE || '', 'utf8');
199
+ } catch {
200
+ fail();
201
+ }
202
+ const dotted = String(process.env.UKIT_SALVAGE_FIELD || '').split('.').filter(Boolean);
203
+ if (!data || data[0] !== '{' || dotted.length === 0 || dotted.length > 4) fail();
204
+
205
+ const isWs = (c) => c === ' ' || c === '\t' || c === '\n' || c === '\r';
206
+ const skipWs = (s, i) => { while (i < s.length && isWs(s[i])) i += 1; return i; };
207
+ // End index of the string literal starting at `start` (which must be `"`), or -1
208
+ // when the string is cut before its closing quote.
209
+ const scanStringEnd = (s, start) => {
210
+ for (let i = start + 1; i < s.length; i += 1) {
211
+ const c = s[i];
212
+ if (c === '\\') { i += 1; continue; }
213
+ if (c === '"') return i;
214
+ }
215
+ return -1;
216
+ };
217
+ // Closing brace matching the `{` at `open` (string-aware), or -1 if unclosed.
218
+ const matchBrace = (s, open) => {
219
+ let depth = 0;
220
+ for (let i = open; i < s.length; i += 1) {
221
+ const c = s[i];
222
+ if (c === '"') {
223
+ const end = scanStringEnd(s, i);
224
+ if (end === -1) return -1;
225
+ i = end;
226
+ continue;
227
+ }
228
+ if (c === '{') depth += 1;
229
+ else if (c === '}') {
230
+ depth -= 1;
231
+ if (depth === 0) return i;
232
+ }
233
+ }
234
+ return -1;
235
+ };
236
+ // Find `"key"` used as an object key inside region [lo, hi); returns the index
237
+ // of its `:` or -1. A bare `"key"` inside a string value cannot produce this
238
+ // shape (its quotes are escaped), and non-key uses lack the `:` — both are
239
+ // skipped by scanning forward.
240
+ const findKey = (s, key, lo, hi) => {
241
+ const needle = `"${key}"`;
242
+ let pos = s.indexOf(needle, lo);
243
+ while (pos !== -1 && pos < hi) {
244
+ const colon = skipWs(s, pos + needle.length);
245
+ if (colon < hi && colon < s.length && s[colon] === ':') {
246
+ const prev = pos - 1;
247
+ const pc = prev >= 0 ? s[prev] : '';
248
+ if (prev < 0 || pc === '{' || pc === ',' || isWs(pc)) return colon;
249
+ }
250
+ pos = s.indexOf(needle, pos + 1);
251
+ }
252
+ return -1;
253
+ };
254
+
255
+ let regionLo = 0;
256
+ let regionHi = data.length;
257
+ for (let k = 0; k < dotted.length; k += 1) {
258
+ const colon = findKey(data, dotted[k], regionLo, regionHi);
259
+ if (colon === -1) fail();
260
+ const vstart = skipWs(data, colon + 1);
261
+ if (vstart >= data.length) fail();
262
+ const last = k === dotted.length - 1;
263
+ const c = data[vstart];
264
+ if (!last) {
265
+ if (c !== '{') fail();
266
+ const close = matchBrace(data, vstart);
267
+ // An unclosed parent object still bounds the search to what arrived; the
268
+ // leaf's own boundary proof below decides completeness.
269
+ regionLo = vstart + 1;
270
+ regionHi = close === -1 ? data.length : close;
271
+ continue;
272
+ }
273
+ if (c !== '"') fail(); // string fields only
274
+ const end = scanStringEnd(data, vstart);
275
+ if (end === -1) fail(); // cut inside the value — never trust a partial field
276
+ const after = skipWs(data, end + 1);
277
+ if (after >= data.length) fail(); // closed quote but no boundary proof — err closed
278
+ const boundary = data[after];
279
+ if (boundary !== ',' && boundary !== '}') fail();
280
+ try {
281
+ process.stdout.write(JSON.parse(data.slice(vstart, end + 1)));
282
+ } catch {
283
+ fail();
284
+ }
285
+ process.exit(0);
286
+ }
287
+ fail();
288
+ UKIT_SALVAGE_NODE
289
+ }
290
+
172
291
  ukit_cleanup_hook_input() {
173
292
  # TASK-019: emit the telemetry finish marker while the staged payload file
174
293
  # still exists (its mtime is the envelope start). Strictly advisory — the