@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.
- package/CHANGELOG.md +70 -0
- package/manifests/documentation.yaml +24 -2
- package/manifests/instructionRules.yaml +62 -0
- package/package.json +1 -1
- package/scripts/perf/audit-perf.mjs +920 -0
- package/src/cli/commands/doctor.js +23 -4
- package/src/core/diffPlan.js +8 -0
- package/src/core/ompConfigMerge.js +222 -0
- package/src/core/runInstallPipeline.js +11 -0
- package/src/core/runtimeConfig.js +11 -0
- package/src/core/unattendedDoctor.js +227 -0
- package/templates/.claude/hooks/block-dangerous.sh +76 -9
- package/templates/.claude/hooks/context-hardcap-gate.sh +26 -8
- package/templates/.claude/hooks/project-important.sh +70 -9
- package/templates/.claude/hooks/protect-files.sh +24 -7
- package/templates/.claude/hooks/sensitive-data-guard.sh +57 -5
- package/templates/.claude/settings.json +22 -117
- package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +217 -10
- package/templates/.claude/ukit/runtime/hook-input.sh +119 -0
- package/templates/.omp/config.yml +32 -4
- package/templates/.omp/hooks/pre/ukit-bridge.js +12 -1
- package/templates/AGENTS.md +22 -10
- package/templates/CLAUDE.md +22 -10
- package/templates/adapter-presets/opencode/opencode.template.json +1 -1
- package/templates/instructions/core.md +22 -10
- package/templates/instructions/layout.yaml +12 -12
- package/templates/ukit/storage/config.json +2 -0
|
@@ -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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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,
|
|
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
|
|
75
|
-
const
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|