@ngockhoale/ukit 2.4.2 → 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.
- package/manifests/platform.full.yaml +19 -111
- package/package.json +2 -1
- package/scripts/index/refresh-index.mjs +47 -22
- package/src/core/compact/threshold.js +36 -6
- package/src/diagnostics/classifyHang.js +246 -0
- package/src/index/buildIndex.js +1033 -62
- package/templates/.claude/hooks/auto-allow-bash.sh +82 -93
- package/templates/.claude/hooks/block-dangerous.sh +31 -5
- package/templates/.claude/hooks/completion-gate.sh +51 -10
- package/templates/.claude/hooks/compress-output.sh +38 -6
- package/templates/.claude/hooks/context-hardcap-gate.sh +35 -6
- package/templates/.claude/hooks/context-window-guard.sh +128 -18
- package/templates/.claude/hooks/handoff-model-guard.sh +31 -5
- package/templates/.claude/hooks/handoff-resume.sh +31 -5
- package/templates/.claude/hooks/post-edit-verify.sh +31 -5
- package/templates/.claude/hooks/pre-edit-backup.sh +31 -5
- package/templates/.claude/hooks/protect-files.sh +31 -5
- package/templates/.claude/hooks/record-execution.sh +31 -5
- package/templates/.claude/hooks/sensitive-data-guard.sh +44 -8
- package/templates/.claude/hooks/skill-router.sh +31 -5
- package/templates/.claude/hooks/stale-spec-guard.sh +31 -5
- package/templates/.claude/hooks/task-watchdog.sh +108 -123
- package/templates/.claude/hooks/verification-guard.sh +107 -112
- package/templates/.claude/hooks/vision-router.sh +49 -13
- package/templates/.claude/settings.json +0 -5
- package/templates/.claude/ukit/index/lib/index-core.mjs +960 -63
- package/templates/.claude/ukit/index/refresh-index.mjs +47 -22
- package/templates/.claude/ukit/index/route-task.mjs +610 -4
- package/templates/.claude/ukit/runtime/async-lock.mjs +340 -0
- package/templates/.claude/ukit/runtime/compact-threshold.mjs +73 -24
- package/templates/.claude/ukit/runtime/context-capacity.mjs +144 -0
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +664 -170
- package/templates/.claude/ukit/runtime/hook-chain-budget.mjs +92 -0
- package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +84 -29
- package/templates/.claude/ukit/runtime/hook-input.sh +85 -5
- package/templates/.claude/ukit/runtime/hook-payload-store.mjs +160 -0
- package/templates/.claude/ukit/runtime/hook-process.mjs +250 -0
- package/templates/.claude/ukit/runtime/hook-telemetry.mjs +255 -0
- package/templates/.claude/ukit/runtime/hook-telemetry.sh +60 -0
- package/templates/.claude/ukit/runtime/stop-coordinator.mjs +509 -0
- package/templates/.claude/ukit/runtime/task-watchdog.mjs +180 -6
- package/templates/.claude/ukit/runtime/transcript-tail.mjs +1 -1
- package/templates/.omp/hooks/pre/ukit-bridge.js +171 -57
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* task-watchdog.mjs — wall-clock watchdog runtime for handoff tasks.
|
|
4
4
|
*
|
|
5
|
-
* Self-contained runtime module. No imports from `src
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Self-contained runtime module. No imports from `src/`; the one allowed sibling
|
|
6
|
+
* import is the shared hook async-lock runtime (TASK-028, `./async-lock.mjs`) —
|
|
7
|
+
* since TASK-024, every state mutation is a lock-scoped read-modify-write through
|
|
8
|
+
* it, because atomic temp+rename alone still loses updates when two concurrent
|
|
9
|
+
* Stop hooks read-modify-write the same counters (last-writer-wins). The lock's
|
|
10
|
+
* budget-capped, never-callback-unlocked design keeps the mutation inside the
|
|
11
|
+
* hook deadline: an expired acquisition budget is a typed fail-open outcome, and
|
|
12
|
+
* nothing is ever written unlocked.
|
|
9
13
|
*
|
|
10
14
|
* Exports:
|
|
11
15
|
* DEFAULT_CONFIG — fail-open defaults; identical to the
|
|
@@ -23,7 +27,19 @@
|
|
|
23
27
|
* readState(statePath) / writeState(statePath, value) —
|
|
24
28
|
* `.ukit/storage/cache/task-watchdog/state.json`,
|
|
25
29
|
* shape { firstSeen, hardBlocks }; any I/O error
|
|
26
|
-
* → treat as empty, never throw.
|
|
30
|
+
* → treat as empty, never throw. writeState is
|
|
31
|
+
* the low-level persist helper — call it only
|
|
32
|
+
* from inside mutateWatchdogState (or tests).
|
|
33
|
+
* mutateWatchdogState(statePath, updater, {signal, deadlineMs}) —
|
|
34
|
+
* the ONLY way to mutate state: one lock-scoped
|
|
35
|
+
* read → updater → write. Committed state, or a
|
|
36
|
+
* typed `lock-timeout` outcome with no mutation.
|
|
37
|
+
* evaluateStopWatchdog({...}) — TASK-025: the Stop-branch evaluation extracted
|
|
38
|
+
* from task-watchdog.sh's heredoc. Returns a
|
|
39
|
+
* normalized { kind, reason?, systemMessage? }
|
|
40
|
+
* result for the stop coordinator, which merges
|
|
41
|
+
* it with the completion gate's decision and
|
|
42
|
+
* emits at most one Stop decision.
|
|
27
43
|
*
|
|
28
44
|
* The hard clock does not reset on Progress appends on purpose: trickling entries
|
|
29
45
|
* every 4 minutes must not buy an unlimited task. The newest Progress timestamp is
|
|
@@ -33,6 +49,8 @@
|
|
|
33
49
|
import fs from 'node:fs/promises';
|
|
34
50
|
import path from 'node:path';
|
|
35
51
|
|
|
52
|
+
import { withAsyncLock } from './async-lock.mjs';
|
|
53
|
+
|
|
36
54
|
// ─── Defaults ─────────────────────────────────────────────────────────────
|
|
37
55
|
export const DEFAULT_CONFIG = Object.freeze({
|
|
38
56
|
taskBudgets: {
|
|
@@ -126,15 +144,65 @@ export async function readState(statePath) {
|
|
|
126
144
|
|
|
127
145
|
export async function writeState(statePath, value) {
|
|
128
146
|
if (!statePath) return;
|
|
147
|
+
let tempPath = null;
|
|
129
148
|
try {
|
|
130
149
|
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
|
131
150
|
// temp + rename to avoid leaving a half-written state file if the hook is killed.
|
|
132
|
-
|
|
151
|
+
tempPath = `${statePath}.${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.tmp`;
|
|
133
152
|
await fs.writeFile(tempPath, `${JSON.stringify(value, null, 1)}\n`, 'utf8');
|
|
134
153
|
await fs.rename(tempPath, statePath);
|
|
154
|
+
tempPath = null; // renamed into place — nothing left to clean up
|
|
135
155
|
} catch {
|
|
136
156
|
// Fail-open: never throw from state writes.
|
|
157
|
+
} finally {
|
|
158
|
+
if (tempPath) {
|
|
159
|
+
// TASK-024: a failed write/rename must not leak its temp file.
|
|
160
|
+
try {
|
|
161
|
+
await fs.rm(tempPath, { force: true });
|
|
162
|
+
} catch {}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Lock-scoped read-modify-write of the watchdog state file (TASK-024). The ONLY
|
|
169
|
+
* sanctioned way to mutate state: read → updater → write all happen inside one
|
|
170
|
+
* `withAsyncLock` acquisition, so concurrent Stop hooks can never lose each
|
|
171
|
+
* other's firstSeen anchors or hard-block counts to last-writer-wins.
|
|
172
|
+
*
|
|
173
|
+
* The updater receives the freshly read state and mutates it in place (matching
|
|
174
|
+
* the existing ensureFirstSeen / bumpHardBlocks helpers); its return value is
|
|
175
|
+
* ignored and the mutated state is what gets persisted. An expired acquisition
|
|
176
|
+
* budget or an aborted signal is a typed fail-open outcome: the updater NEVER
|
|
177
|
+
* runs unlocked and nothing is written — the caller picks the policy.
|
|
178
|
+
*
|
|
179
|
+
* @param {string} statePath - state file to mutate (lock lives beside it)
|
|
180
|
+
* @param {(state: { firstSeen: Record<string, number>, hardBlocks: Record<string, number> }) => (void | Promise<void>)} updater
|
|
181
|
+
* @param {{ signal?: AbortSignal, deadlineMs?: number }} [options]
|
|
182
|
+
* @returns {Promise<{ ok: true, state: * } | { ok: false, reason: 'lock-timeout' | 'aborted', waitedMs: number }>}
|
|
183
|
+
* committed state, or the typed no-mutation outcome
|
|
184
|
+
*/
|
|
185
|
+
export async function mutateWatchdogState(statePath, updater, { signal, deadlineMs } = {}) {
|
|
186
|
+
if (typeof statePath !== 'string' || !statePath) {
|
|
187
|
+
throw new TypeError('mutateWatchdogState(statePath, updater, options) requires a state path');
|
|
188
|
+
}
|
|
189
|
+
if (typeof updater !== 'function') {
|
|
190
|
+
throw new TypeError('mutateWatchdogState(statePath, updater, options) requires an updater function');
|
|
137
191
|
}
|
|
192
|
+
const outcome = await withAsyncLock(statePath, { signal, deadlineMs }, async () => {
|
|
193
|
+
const state = await readState(statePath);
|
|
194
|
+
await updater(state);
|
|
195
|
+
await writeState(statePath, state);
|
|
196
|
+
return state;
|
|
197
|
+
});
|
|
198
|
+
if (outcome.ok) {
|
|
199
|
+
return { ok: true, state: outcome.value };
|
|
200
|
+
}
|
|
201
|
+
// 'busy' (acquisition budget expired) is what the watchdog interface names a
|
|
202
|
+
// lock-timeout; 'aborted' passes through unchanged. Either way nothing ran.
|
|
203
|
+
return outcome.reason === 'aborted'
|
|
204
|
+
? { ok: false, reason: 'aborted', waitedMs: outcome.waitedMs }
|
|
205
|
+
: { ok: false, reason: 'lock-timeout', waitedMs: outcome.waitedMs };
|
|
138
206
|
}
|
|
139
207
|
|
|
140
208
|
// ─── Task parser ──────────────────────────────────────────────────────────
|
|
@@ -297,3 +365,109 @@ export function pickLastGreen(task) {
|
|
|
297
365
|
if (!task?.lastProgressIso) return null;
|
|
298
366
|
return { iso: task.lastProgressIso, label: 'milestone' };
|
|
299
367
|
}
|
|
368
|
+
|
|
369
|
+
// ─── Stop evaluation (TASK-025) ───────────────────────────────────────────
|
|
370
|
+
/**
|
|
371
|
+
* Evaluate the watchdog policy for ONE Stop event and return a normalized result
|
|
372
|
+
* the stop coordinator (stop-coordinator.mjs) can merge with the completion gate's
|
|
373
|
+
* decision:
|
|
374
|
+
* { kind: 'block', reason }
|
|
375
|
+
* { kind: 'advisory', systemMessage }
|
|
376
|
+
* { kind: 'none' }
|
|
377
|
+
*
|
|
378
|
+
* Semantics are the extracted Stop branch of task-watchdog.sh, unchanged:
|
|
379
|
+
* anchors firstSeen once per task in one lock-scoped read-modify-write, then
|
|
380
|
+
* evaluates budgets; split policy + hard trip bumps the task's hardBlocks counter
|
|
381
|
+
* exactly once (cap-checked inside the same critical section); pause policy and
|
|
382
|
+
* degraded/cap-exhausted paths are advisory only and never emit a decision.
|
|
383
|
+
*
|
|
384
|
+
* Throws on infrastructure failure — the coordinator owns the failure policy
|
|
385
|
+
* (advisory lane: reported verbatim, never blocking the completion decision).
|
|
386
|
+
*
|
|
387
|
+
* @param {object} [options]
|
|
388
|
+
* @param {string} options.projectRoot - installed project root
|
|
389
|
+
* @param {number} [options.now] - evaluation clock (epochMs)
|
|
390
|
+
* @param {object} [options.config] - pre-loaded config (defaults to loadConfig of
|
|
391
|
+
* `<root>/.ukit/storage/config.json`, fail-open defaults)
|
|
392
|
+
* @param {Array} [options.tasks] - pre-discovered in_progress tasks (defaults to
|
|
393
|
+
* listInProgressTasks(projectRoot))
|
|
394
|
+
* @param {number} [options.lockBudgetMs] - per-acquisition lock budget; the
|
|
395
|
+
* coordinator passes a short slice so two acquisitions fit the hook deadline
|
|
396
|
+
*/
|
|
397
|
+
export async function evaluateStopWatchdog({ projectRoot, now = Date.now(), config = null, tasks = null, lockBudgetMs } = {}) {
|
|
398
|
+
if (!config) config = await loadConfig(path.join(projectRoot, '.ukit', 'storage', 'config.json'));
|
|
399
|
+
if (!tasks) tasks = await listInProgressTasks(projectRoot);
|
|
400
|
+
|
|
401
|
+
const statePath = path.join(projectRoot, '.ukit', 'storage', 'cache', 'task-watchdog', 'state.json');
|
|
402
|
+
const state = await readState(statePath);
|
|
403
|
+
const lockOptions = Number.isFinite(lockBudgetMs) ? { deadlineMs: lockBudgetMs } : {};
|
|
404
|
+
|
|
405
|
+
// Anchor firstSeen via one lock-scoped read-modify-write (TASK-024 semantics).
|
|
406
|
+
try {
|
|
407
|
+
await mutateWatchdogState(statePath, (fresh) => {
|
|
408
|
+
for (const task of tasks) {
|
|
409
|
+
try {
|
|
410
|
+
ensureFirstSeen(fresh, task.id, now);
|
|
411
|
+
} catch {}
|
|
412
|
+
}
|
|
413
|
+
}, lockOptions);
|
|
414
|
+
} catch {}
|
|
415
|
+
|
|
416
|
+
const evals = evaluateBudgets({ tasks, state, now, config });
|
|
417
|
+
const hardResults = evals.filter((r) => r.phase === 'hard');
|
|
418
|
+
const softResults = evals.filter((r) => r.phase === 'soft');
|
|
419
|
+
|
|
420
|
+
if (hardResults.length === 0) {
|
|
421
|
+
if (softResults.length > 0) {
|
|
422
|
+
return {
|
|
423
|
+
kind: 'advisory',
|
|
424
|
+
systemMessage: softResults.map((r) => describeSoft(r, r.id)).join('\n'),
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
return { kind: 'none' };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const hardPolicy = String(config.taskBudgets?.hardPolicy || 'split');
|
|
431
|
+
const firstHard = hardResults[0];
|
|
432
|
+
|
|
433
|
+
if (hardPolicy !== 'split') {
|
|
434
|
+
// pause (or anything non-split) → advisory only, never a decision.
|
|
435
|
+
return {
|
|
436
|
+
kind: 'advisory',
|
|
437
|
+
systemMessage: hardResults.map((r) => describePause({ id: r.id, result: r })).join('\n'),
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// split policy + hard trip: the cap check and the bump are ONE lock-scoped
|
|
442
|
+
// read-modify-write — one invocation bumps at most once (TASK-024).
|
|
443
|
+
let bumped = false;
|
|
444
|
+
const bumpOutcome = await mutateWatchdogState(statePath, (fresh) => {
|
|
445
|
+
const current = Number(fresh.hardBlocks[firstHard.id] || 0);
|
|
446
|
+
if (current >= (HARD_BLOCK_CAP || 2)) return;
|
|
447
|
+
try {
|
|
448
|
+
bumpHardBlocks(fresh, firstHard.id);
|
|
449
|
+
bumped = true;
|
|
450
|
+
} catch {}
|
|
451
|
+
}, lockOptions);
|
|
452
|
+
const persistedBlocks = Number(
|
|
453
|
+
(bumpOutcome.ok ? bumpOutcome.state : state)?.hardBlocks?.[firstHard.id] || 0,
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
if (!bumpOutcome.ok || !bumped) {
|
|
457
|
+
return {
|
|
458
|
+
kind: 'advisory',
|
|
459
|
+
systemMessage: describeDegraded({ id: firstHard.id, hardBlocks: persistedBlocks }),
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const task = tasks.find((t) => t.id === firstHard.id) || { id: firstHard.id };
|
|
464
|
+
return {
|
|
465
|
+
kind: 'block',
|
|
466
|
+
reason: describeSplitReason({
|
|
467
|
+
id: firstHard.id,
|
|
468
|
+
result: firstHard,
|
|
469
|
+
hardBlocks: persistedBlocks,
|
|
470
|
+
lastGreen: pickLastGreen(task),
|
|
471
|
+
}),
|
|
472
|
+
};
|
|
473
|
+
}
|
|
@@ -30,7 +30,7 @@ function positiveInteger(value, fallback) {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
async function scanOnce(filePath, maxBytes) {
|
|
33
|
-
const cap = positiveInteger(maxBytes, DEFAULT_TAIL_MAX_BYTES);
|
|
33
|
+
const cap = Math.min(positiveInteger(maxBytes, DEFAULT_TAIL_MAX_BYTES), DEFAULT_TAIL_MAX_BYTES);
|
|
34
34
|
const stat = await fsp.stat(filePath);
|
|
35
35
|
if (!stat.isFile() || stat.size <= 0) {
|
|
36
36
|
return EMPTY_RESULT();
|
|
@@ -18,6 +18,17 @@ import {
|
|
|
18
18
|
readRouteState,
|
|
19
19
|
recordExecutionReceipt,
|
|
20
20
|
} from '../../../.claude/ukit/runtime/execution-ledger.mjs';
|
|
21
|
+
import {
|
|
22
|
+
PAYLOAD_INLINE_MAX_BYTES,
|
|
23
|
+
createPayloadReference,
|
|
24
|
+
maybeSweepStalePayloads,
|
|
25
|
+
probePayloadIntegrity,
|
|
26
|
+
} from '../../../.claude/ukit/runtime/hook-payload-store.mjs';
|
|
27
|
+
// TASK-018 review fix round 1: the chain budget is resolved by ONE shared module,
|
|
28
|
+
// so the runner's inner deadline and this bridge's outer pi.exec timeout can never
|
|
29
|
+
// drift apart (a configured 60-120s chain used to be killed here at 14-20s, which
|
|
30
|
+
// for Edit|Write is a fail-closed transport failure = every edit blocked).
|
|
31
|
+
import { resolveChainExecTimeoutMs } from '../../../.claude/ukit/runtime/hook-chain-budget.mjs';
|
|
21
32
|
|
|
22
33
|
export const HOOK_EVENT_MAP = {
|
|
23
34
|
tool_call: {
|
|
@@ -118,6 +129,48 @@ function classifyFailure(scriptName) {
|
|
|
118
129
|
// whose dangerous-command check timed out stays blocked with the honest reason.
|
|
119
130
|
const TIMEOUT_STAYS_CLOSED = new Set(['block-dangerous.sh']);
|
|
120
131
|
|
|
132
|
+
// TASK-018: the hook-chain-runner's failure taxonomy. Infrastructure outcomes
|
|
133
|
+
// (overflow / timeout / signal / budget-exhausted) produced NO verdict, so their
|
|
134
|
+
// captured output is untrustworthy and never reaches the model context, a block
|
|
135
|
+
// reason, or telemetry. Legacy runner entries (killed without a kind) keep the
|
|
136
|
+
// pre-TASK-018 timeout behavior.
|
|
137
|
+
const INFRASTRUCTURE_FAILURE_KINDS = new Set([
|
|
138
|
+
'output-overflow',
|
|
139
|
+
'timeout',
|
|
140
|
+
'signal',
|
|
141
|
+
'budget-exhausted',
|
|
142
|
+
]);
|
|
143
|
+
|
|
144
|
+
// Telemetry redaction (TASK-018): hook stdout content never enters diagnostics by
|
|
145
|
+
// construction (the runner drops it for infrastructure kinds, and only lengths are
|
|
146
|
+
// recorded); stderr excerpts are bounded and high-signal secret shapes are masked
|
|
147
|
+
// before anything is written to .ukit/storage/cache/hook-errors/ or logged.
|
|
148
|
+
const SECRET_SHAPE_PATTERNS = [
|
|
149
|
+
/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$)/g,
|
|
150
|
+
/\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}\b/g,
|
|
151
|
+
/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}\b/g,
|
|
152
|
+
/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g,
|
|
153
|
+
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
154
|
+
/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}\b/g,
|
|
155
|
+
/\b[0-9a-f]{40,}\b/gi,
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
// TASK-018 review fix round 1 (minor): mask BEFORE truncating. Truncating first
|
|
159
|
+
// cut a secret that straddled the boundary, leaving a fragment whose pattern no
|
|
160
|
+
// longer matched (the truncated head leaked). The raw string is bounded by the
|
|
161
|
+
// runner's maxBuffer, so masking the full text stays cheap.
|
|
162
|
+
function redactDiagnosticText(text, maxChars = 500) {
|
|
163
|
+
const raw = String(text ?? '');
|
|
164
|
+
if (!raw) return '';
|
|
165
|
+
let redacted = raw;
|
|
166
|
+
for (const pattern of SECRET_SHAPE_PATTERNS) {
|
|
167
|
+
redacted = redacted.replace(pattern, '<redacted>');
|
|
168
|
+
}
|
|
169
|
+
return raw.length > maxChars
|
|
170
|
+
? `${redacted.slice(0, maxChars)}…[+${raw.length - maxChars} chars]`
|
|
171
|
+
: redacted;
|
|
172
|
+
}
|
|
173
|
+
|
|
121
174
|
function runtimeMetadata(event = {}, context = {}) {
|
|
122
175
|
const sessionManager = context?.sessionManager;
|
|
123
176
|
return {
|
|
@@ -170,24 +223,61 @@ function parseStructuredDecision(stdout) {
|
|
|
170
223
|
}
|
|
171
224
|
|
|
172
225
|
function translateExecResult(scriptName, execResult) {
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
226
|
+
const failureKind = typeof execResult?.failureKind === 'string' ? execResult.failureKind : null;
|
|
227
|
+
// Infrastructure outcomes produce no verdict: no captured content travels with
|
|
228
|
+
// them (overflowed stdout is truncated mid-stream and may embed secrets).
|
|
229
|
+
const infrastructure = failureKind
|
|
230
|
+
? INFRASTRUCTURE_FAILURE_KINDS.has(failureKind)
|
|
231
|
+
: Boolean(execResult?.killed);
|
|
232
|
+
const stdout = infrastructure ? '' : (execResult?.stdout ?? '');
|
|
233
|
+
const stderr = infrastructure
|
|
234
|
+
? redactDiagnosticText(execResult?.stderr)
|
|
177
235
|
: (execResult?.stderr ?? '');
|
|
178
236
|
|
|
179
|
-
if (
|
|
237
|
+
if (infrastructure) {
|
|
238
|
+
if (failureKind === 'output-overflow') {
|
|
239
|
+
if (FAIL_CLOSED_SCRIPTS.has(scriptName)) {
|
|
240
|
+
return {
|
|
241
|
+
block: true,
|
|
242
|
+
reason: `${scriptName} overflowed its output capture buffer and was stopped before it produced a verdict, so UKit could not verify the call — the call stays blocked and no overflowed hook output was kept.`,
|
|
243
|
+
stdout,
|
|
244
|
+
stderr: '',
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
return {
|
|
248
|
+
block: false,
|
|
249
|
+
warning: `${scriptName} overflowed its output capture buffer and was stopped; its output was discarded — an infrastructure event, not a verdict.`,
|
|
250
|
+
stdout,
|
|
251
|
+
stderr: '',
|
|
252
|
+
};
|
|
253
|
+
}
|
|
180
254
|
if (TIMEOUT_STAYS_CLOSED.has(scriptName)) {
|
|
181
255
|
return {
|
|
182
256
|
block: true,
|
|
183
|
-
reason: `${scriptName} exceeded its hook budget and was killed, so UKit could not verify the command is safe — the call stays blocked. Retry once; if this repeats, the machine is too slow for the guard to finish.`,
|
|
257
|
+
reason: `${scriptName} ${failureKind === 'budget-exhausted' ? 'was never reached — the hook chain total budget was exhausted' : 'exceeded its hook budget and was killed'}, so UKit could not verify the command is safe — the call stays blocked. Retry once; if this repeats, the machine is too slow for the guard to finish.`,
|
|
184
258
|
stdout,
|
|
185
259
|
stderr,
|
|
186
260
|
};
|
|
187
261
|
}
|
|
262
|
+
const why = failureKind === 'signal'
|
|
263
|
+
? 'was terminated by a signal'
|
|
264
|
+
: failureKind === 'budget-exhausted'
|
|
265
|
+
? 'did not run — the hook chain total budget was exhausted before it could'
|
|
266
|
+
: 'exceeded its hook budget and was killed';
|
|
267
|
+
// TASK-018 review finding 3: the 16s ceiling makes a budget-exhausted skip of a
|
|
268
|
+
// LATE fail-closed guard (handoff-model-guard.sh / context-hardcap-gate.sh)
|
|
269
|
+
// realistic, and that guard fails open. This is deliberate anti-freeze policy:
|
|
270
|
+
// a guard that produced NO verdict is an infrastructure event, and blocking on
|
|
271
|
+
// it froze every Edit|Write whenever the machine was slow. Only
|
|
272
|
+
// block-dangerous.sh stays closed (TIMEOUT_STAYS_CLOSED) because destructive-
|
|
273
|
+
// command protection was explicitly required to never fail open. Stated in the
|
|
274
|
+
// warning so a skipped guard is never a silent one.
|
|
275
|
+
const failOpenTradeOff = FAIL_CLOSED_SCRIPTS.has(scriptName) && !TIMEOUT_STAYS_CLOSED.has(scriptName)
|
|
276
|
+
? ` ${scriptName} is a fail-closed guard, but a guard that never produced a verdict is treated as "could not verify" rather than a block — the deliberate anti-freeze trade-off for infrastructure events; only block-dangerous.sh stays closed.`
|
|
277
|
+
: '';
|
|
188
278
|
return {
|
|
189
279
|
block: false,
|
|
190
|
-
warning: `${scriptName}
|
|
280
|
+
warning: `${scriptName} ${why} — treated as "could not verify", not as a block (an infrastructure event, not a verdict): ${stderr || 'no stderr'}.${failOpenTradeOff}`,
|
|
191
281
|
stdout,
|
|
192
282
|
stderr,
|
|
193
283
|
};
|
|
@@ -224,7 +314,7 @@ function translateExecResult(scriptName, execResult) {
|
|
|
224
314
|
}
|
|
225
315
|
return {
|
|
226
316
|
block: false,
|
|
227
|
-
warning: `${scriptName} exited ${code} (failing open): ${stderr || 'no stderr'}`,
|
|
317
|
+
warning: `${scriptName} exited ${code} (failing open): ${redactDiagnosticText(stderr) || 'no stderr'}`,
|
|
228
318
|
stdout,
|
|
229
319
|
stderr,
|
|
230
320
|
};
|
|
@@ -232,15 +322,13 @@ function translateExecResult(scriptName, execResult) {
|
|
|
232
322
|
|
|
233
323
|
export { translateExecResult };
|
|
234
324
|
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
return Math.min(30000, budget + 2000);
|
|
243
|
-
}
|
|
325
|
+
// The exec timeout must exceed the runner's own chain budget (resolved from the
|
|
326
|
+
// SAME UKIT_HOOK_CHAIN_* knobs by hook-chain-budget.mjs) plus enough margin to
|
|
327
|
+
// cover the runner's TERM→KILL grace and hard settle slack, or the bridge orphans
|
|
328
|
+
// the runner mid-chain. TASK-018: the ceiling caps what used to be linear growth.
|
|
329
|
+
// Review fix round 1: this used to be a separate hardcoded formula under a fixed
|
|
330
|
+
// 30s cap that silently disagreed with the runner whenever the env knobs were set.
|
|
331
|
+
const chainExecTimeoutMs = resolveChainExecTimeoutMs;
|
|
244
332
|
|
|
245
333
|
function recordHookErrorDiagnostic(projectRoot, sessionId, diagnostic) {
|
|
246
334
|
try {
|
|
@@ -277,37 +365,25 @@ function resolveNodeExecutable() {
|
|
|
277
365
|
return cachedNodeExecutable;
|
|
278
366
|
}
|
|
279
367
|
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
const filePath = path.join(dir, name);
|
|
290
|
-
try {
|
|
291
|
-
if (fs.statSync(filePath).mtimeMs < cutoff) fs.rmSync(filePath, { force: true });
|
|
292
|
-
} catch { /* raced away — fine */ }
|
|
293
|
-
}
|
|
294
|
-
} catch { /* sweeping is best effort */ }
|
|
368
|
+
// TASK-031: payload staging moved to hook-payload-store.mjs. Small payloads stay inline
|
|
369
|
+
// (zero filesystem work); over-cap payloads stage atomically into an owner-only file
|
|
370
|
+
// (macOS allows ~256KB per argument (E2BIG), and PostToolUse Bash payloads embed whole
|
|
371
|
+
// tool outputs — routinely past that limit, so the exec would fail before ANY hook runs
|
|
372
|
+
// and the chain verdict would be lost). Stale sweeping is sampled, bounded, and deferred
|
|
373
|
+
// off the request path; inline argv remains the fallback for read-only roots, unambiguous
|
|
374
|
+
// because raw JSON never starts with '@'.
|
|
375
|
+
function payloadsDirFor(projectRoot) {
|
|
376
|
+
return path.join(projectRoot, '.ukit', 'storage', 'cache', 'hook-payloads');
|
|
295
377
|
}
|
|
296
378
|
|
|
297
|
-
function
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
);
|
|
306
|
-
fs.writeFileSync(filePath, JSON.stringify(payload), 'utf8');
|
|
307
|
-
return filePath;
|
|
308
|
-
} catch {
|
|
309
|
-
return null;
|
|
310
|
-
}
|
|
379
|
+
function schedulePayloadSweep(projectRoot) {
|
|
380
|
+
// Deferred: runs after the current turn settles, never inside the chain request.
|
|
381
|
+
const timer = setTimeout(() => {
|
|
382
|
+
try {
|
|
383
|
+
maybeSweepStalePayloads(payloadsDirFor(projectRoot));
|
|
384
|
+
} catch { /* deferred sweeping is best effort */ }
|
|
385
|
+
}, 0);
|
|
386
|
+
timer.unref?.();
|
|
311
387
|
}
|
|
312
388
|
|
|
313
389
|
export async function runScriptChain(
|
|
@@ -326,9 +402,13 @@ export async function runScriptChain(
|
|
|
326
402
|
const scriptPaths = scripts.map((scriptName) => path.join(projectRoot, '.claude', 'hooks', scriptName));
|
|
327
403
|
const nodeExecutable = resolveNodeExecutable();
|
|
328
404
|
const startedAt = Date.now();
|
|
329
|
-
const
|
|
330
|
-
|
|
405
|
+
const payloadReference = createPayloadReference(JSON.stringify(payload), {
|
|
406
|
+
maxBytes: PAYLOAD_INLINE_MAX_BYTES,
|
|
407
|
+
dir: payloadsDirFor(projectRoot),
|
|
408
|
+
});
|
|
409
|
+
const payloadArg = payloadReference.arg;
|
|
331
410
|
let execResult;
|
|
411
|
+
let payloadProbe = null;
|
|
332
412
|
try {
|
|
333
413
|
execResult = await pi.exec(
|
|
334
414
|
nodeExecutable,
|
|
@@ -338,9 +418,12 @@ export async function runScriptChain(
|
|
|
338
418
|
} catch (error) {
|
|
339
419
|
execResult = { code: 1, stdout: '', stderr: error?.message ?? String(error), killed: false };
|
|
340
420
|
} finally {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
421
|
+
// TASK-031: verify the staged payload survived the chain intact BEFORE removing it —
|
|
422
|
+
// a file that vanished or was truncated mid-flight means the scripts ran against a
|
|
423
|
+
// different payload than the host captured, so their verdicts are void.
|
|
424
|
+
payloadProbe = probePayloadIntegrity(payloadReference);
|
|
425
|
+
payloadReference.cleanup();
|
|
426
|
+
if (payloadReference.mode === 'file') schedulePayloadSweep(projectRoot);
|
|
344
427
|
}
|
|
345
428
|
const elapsedMs = Date.now() - startedAt;
|
|
346
429
|
|
|
@@ -355,7 +438,11 @@ export async function runScriptChain(
|
|
|
355
438
|
}
|
|
356
439
|
|
|
357
440
|
const hasUsableResults = Boolean(chainResult) && Array.isArray(chainResult.results) && chainResult.results.length > 0;
|
|
358
|
-
const transportFailed =
|
|
441
|
+
const transportFailed = payloadProbe !== null
|
|
442
|
+
|| Boolean(execResult?.killed)
|
|
443
|
+
|| Boolean(parseError)
|
|
444
|
+
|| Boolean(chainResult?.wrapperError)
|
|
445
|
+
|| !hasUsableResults;
|
|
359
446
|
|
|
360
447
|
if (transportFailed) {
|
|
361
448
|
// The aggregate runner produced no verifiable per-script verdict. Never relabel this as a
|
|
@@ -366,7 +453,13 @@ export async function runScriptChain(
|
|
|
366
453
|
killed: Boolean(execResult?.killed),
|
|
367
454
|
code: execResult?.code ?? null,
|
|
368
455
|
stdoutLength: (execResult?.stdout || '').length,
|
|
369
|
-
|
|
456
|
+
// TASK-018: bounded + secret-masked excerpt only — raw hook/runner output
|
|
457
|
+
// never reaches the hook-errors telemetry file.
|
|
458
|
+
stderrExcerpt: redactDiagnosticText(execResult?.stderr),
|
|
459
|
+
// TASK-031: payload integrity is recorded as a classification + byte count,
|
|
460
|
+
// never as content.
|
|
461
|
+
payloadProbe,
|
|
462
|
+
payloadBytes: payloadReference.bytes,
|
|
370
463
|
elapsedMs,
|
|
371
464
|
nodeExecutable,
|
|
372
465
|
nodeVersion: process.version,
|
|
@@ -375,15 +468,36 @@ export async function runScriptChain(
|
|
|
375
468
|
wrapperError: chainResult?.wrapperError || null,
|
|
376
469
|
};
|
|
377
470
|
recordHookErrorDiagnostic(projectRoot, payload.session_id, diagnostic);
|
|
471
|
+
// TASK-031: a staged payload that was lost or corrupted mid-chain voids every
|
|
472
|
+
// verdict below it. Chains that must not fail open on an unverifiable verdict
|
|
473
|
+
// (Edit|Write transport policy, and block-dangerous.sh's never-fail-open rule)
|
|
474
|
+
// stay closed; everything else fails open loudly. Neither reason carries any
|
|
475
|
+
// payload content — only the classification and byte count.
|
|
476
|
+
const payloadStaysClosed = payloadProbe !== null
|
|
477
|
+
&& (failClosedOnTransportError || scripts.includes('block-dangerous.sh'));
|
|
478
|
+
if (payloadStaysClosed) {
|
|
479
|
+
const integrityReason = `UKit hook payload transport failed: the staged payload file was `
|
|
480
|
+
+ `${payloadProbe === 'missing' ? 'removed' : 'truncated'} before the chain could read it `
|
|
481
|
+
+ `(expected ${payloadReference.bytes} bytes), so the scripts may have run against an empty `
|
|
482
|
+
+ `or partial payload and their verdicts were discarded — the call could not be verified. `
|
|
483
|
+
+ `See .ukit/storage/cache/hook-errors/.`;
|
|
484
|
+
return { block: true, reason: integrityReason, context, invoked };
|
|
485
|
+
}
|
|
378
486
|
const nodePathHint = process.env.UKIT_NODE_PATH
|
|
379
487
|
? ''
|
|
380
488
|
: ` UKit already tried process.execPath and a "node" PATH lookup; neither resolved to a `
|
|
381
489
|
+ `working Node.js binary (runtime="${diagnostic.nodeExecutable}"). Set UKIT_NODE_PATH to `
|
|
382
490
|
+ `an explicit Node.js binary path to override, e.g.: export UKIT_NODE_PATH="$(command -v node)".`;
|
|
383
|
-
const reason =
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
491
|
+
const reason = payloadProbe !== null
|
|
492
|
+
? `UKit hook payload transport failed: the staged payload file was `
|
|
493
|
+
+ `${payloadProbe === 'missing' ? 'removed' : 'truncated'} before the chain could read it `
|
|
494
|
+
+ `(expected ${payloadReference.bytes} bytes), so the scripts may have run against an empty `
|
|
495
|
+
+ `or partial payload and their verdicts were discarded — treated as "could not verify", `
|
|
496
|
+
+ `not as a block. See .ukit/storage/cache/hook-errors/.`
|
|
497
|
+
: `UKit OMP hook runner failed before producing a valid result `
|
|
498
|
+
+ `(killed=${diagnostic.killed}, code=${diagnostic.code}, elapsedMs=${diagnostic.elapsedMs}, `
|
|
499
|
+
+ `runtime=${diagnostic.nodeExecutable}). No safety-gate verdict was available for [${scripts.join(', ')}]. `
|
|
500
|
+
+ `See .ukit/storage/cache/hook-errors/.${nodePathHint}`;
|
|
387
501
|
if (failClosedOnTransportError) {
|
|
388
502
|
return { block: true, reason, context, invoked };
|
|
389
503
|
}
|