@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.
- package/CHANGELOG.md +57 -0
- package/README.md +20 -0
- package/manifests/platform.full.yaml +51 -112
- package/package.json +2 -1
- package/scripts/index/refresh-index.mjs +47 -22
- package/src/cli/commands/doctor.js +132 -2
- package/src/cli/commands/uninstall.js +18 -0
- package/src/core/applyPlan.js +17 -2
- package/src/core/compact/threshold.js +36 -6
- package/src/core/diffPlan.js +35 -0
- package/src/core/fileOps.js +26 -0
- package/src/core/projectImportant.js +430 -0
- package/src/core/sensitiveValueScanner.js +118 -0
- package/src/core/status.js +55 -1
- package/src/core/uninstall.js +183 -3
- 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/project-important.sh +67 -0
- 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 +124 -56
- 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 +5 -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/project-important.mjs +381 -0
- package/templates/.claude/ukit/runtime/sensitive-value-scanner.mjs +128 -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 +178 -61
- package/templates/AGENTS.md +8 -0
- 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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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 =
|
|
65
|
-
|
|
66
|
-
|
|
98
|
+
const result = await runHookProcess({
|
|
99
|
+
command: scriptPath,
|
|
100
|
+
args: [],
|
|
67
101
|
input: payloadText,
|
|
68
|
-
|
|
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
|
|
73
|
-
const
|
|
74
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
|
|
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
|
+
}
|