@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
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
# 1. Read/Grep of secret files (.env*, *.pem, id_rsa, credentials.json, ...)
|
|
12
12
|
# 2. Bash commands that dump secrets (cat .env, bare `env`, curl -u user:pass,
|
|
13
13
|
# URL-embedded credentials, any high-confidence token in the command text)
|
|
14
|
-
# 3. UserPromptSubmit prompt text containing high-confidence secret patterns
|
|
14
|
+
# 3. UserPromptSubmit prompt text containing high-confidence secret patterns;
|
|
15
|
+
# complete machine-generated envelopes are delivered with a redacted advisory
|
|
15
16
|
#
|
|
16
17
|
# FAIL-CLOSED on detection (exit 2). Never echoes the detected value — only a
|
|
17
18
|
# redacted preview plus a truncated sha256 (12 hex chars: enough for the human
|
|
@@ -26,12 +27,18 @@
|
|
|
26
27
|
# a broken gate must not brick every session, but every real detection blocks.
|
|
27
28
|
|
|
28
29
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
30
|
+
HOOK_DIR="$SCRIPT_DIR"
|
|
31
|
+
SCRIPT_PATH="$HOOK_DIR/../ukit/runtime/sensitive-value-scanner.mjs"
|
|
29
32
|
PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
30
33
|
# Bounded stdin (H01) — fail-closed overflow: an oversized payload is REFUSED
|
|
31
34
|
# (silent exit 2, nothing staged, nothing echoed). The gate never truncates
|
|
32
35
|
# unknown overflow and never materializes it in a shell variable.
|
|
33
36
|
# shellcheck source=/dev/null
|
|
34
37
|
if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
|
|
38
|
+
# TASK-019: arm advisory latency telemetry; the finish row is written by the
|
|
39
|
+
# shared cleanup path before the staged payload is removed. A missing
|
|
40
|
+
# runtime (pre-install tree) simply leaves telemetry off.
|
|
41
|
+
source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
|
|
35
42
|
trap ukit_cleanup_hook_input EXIT
|
|
36
43
|
ukit_stage_hook_input 2097152 refuse
|
|
37
44
|
STAGE_RC=$?
|
|
@@ -39,18 +46,41 @@ if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
|
|
|
39
46
|
exit "$STAGE_RC"
|
|
40
47
|
fi
|
|
41
48
|
else
|
|
42
|
-
# Runtime helper missing (pre-install tree):
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if [
|
|
49
|
+
# Runtime helper missing (pre-install tree): the SAME bounded staging,
|
|
50
|
+
# inline - `cat >/dev/null` used to block forever on a producer that never
|
|
51
|
+
# closes the pipe (R4.5). Read cap+1 in the background, bound the wait.
|
|
52
|
+
UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
|
|
53
|
+
if [ -e /dev/fd/0 ]; then
|
|
54
|
+
exec 8<&0
|
|
55
|
+
head -c 2097153 <&8 > "$UKIT_INPUT_FILE" 2>/dev/null &
|
|
56
|
+
else
|
|
57
|
+
head -c 2097153 > "$UKIT_INPUT_FILE" 2>/dev/null &
|
|
58
|
+
fi
|
|
59
|
+
__ukit_reader=$!
|
|
60
|
+
__ukit_stage_ms="${UKIT_HOOK_STAGE_MS:-2000}"
|
|
61
|
+
case "$__ukit_stage_ms" in
|
|
62
|
+
''|*[!0-9]*) __ukit_stage_ms=2000 ;;
|
|
63
|
+
esac
|
|
64
|
+
[ "$__ukit_stage_ms" -gt 0 ] 2>/dev/null || __ukit_stage_ms=2000
|
|
65
|
+
printf -v __ukit_stage_s '%d.%03d' $((__ukit_stage_ms / 1000)) $((__ukit_stage_ms % 1000))
|
|
66
|
+
( sleep "$__ukit_stage_s" 2>/dev/null; kill -9 "$__ukit_reader" 2>/dev/null ) <&- >/dev/null 2>&1 &
|
|
67
|
+
__ukit_waiter=$!
|
|
68
|
+
wait "$__ukit_reader" 2>/dev/null
|
|
69
|
+
__ukit_stage_rc=$?
|
|
70
|
+
kill "$__ukit_waiter" 2>/dev/null
|
|
71
|
+
wait "$__ukit_waiter" 2>/dev/null
|
|
72
|
+
exec 8<&-
|
|
73
|
+
__ukit_size="$(wc -c < "$UKIT_INPUT_FILE" 2>/dev/null | tr -d '[:space:]')"
|
|
74
|
+
if [ "$__ukit_stage_rc" -ne 0 ] || [ "$__ukit_size" -gt 2097152 ]; then
|
|
75
|
+
# Fail-closed: an unstaged/oversized payload was never inspected
|
|
76
|
+
# (exit 2 blocks; 1 would be a non-blocking hook error).
|
|
47
77
|
rm -f "$UKIT_INPUT_FILE"
|
|
48
78
|
exit 2
|
|
49
79
|
fi
|
|
50
80
|
trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
|
|
51
81
|
fi
|
|
52
82
|
|
|
53
|
-
INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE'
|
|
83
|
+
INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" SCRIPT_PATH="$SCRIPT_PATH" UKIT_HOOK_DEADLINE_MS="${UKIT_HOOK_DEADLINE_MS:-3000}" node <<'NODE'
|
|
54
84
|
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
|
|
55
85
|
setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
|
|
56
86
|
const fs = require('fs');
|
|
@@ -104,37 +134,44 @@ function pathAllowed(filePath) {
|
|
|
104
134
|
return allowedPaths.some((allowed) => allowed === normalized || allowed === relative);
|
|
105
135
|
}
|
|
106
136
|
|
|
107
|
-
// --- high-confidence secret value
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
{
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
// keyword, blocking code, prose and shell syntax that is never a secret.
|
|
128
|
-
function scanSecretText(text) {
|
|
129
|
-
const found = [];
|
|
130
|
-
for (const { label, re } of TOKEN_PATTERNS) {
|
|
131
|
-
re.lastIndex = 0;
|
|
132
|
-
let match;
|
|
133
|
-
while ((match = re.exec(text)) !== null) {
|
|
134
|
-
found.push({ label, value: match[0] });
|
|
135
|
-
}
|
|
137
|
+
// --- high-confidence secret value scan (TASK-039: shared runtime module) ---
|
|
138
|
+
// The token-pattern definitions and matching live in the shared runtime
|
|
139
|
+
// module ukit/runtime/sensitive-value-scanner.mjs (manifest requires:
|
|
140
|
+
// ukit-runtime-scripts) — there is deliberately NO local pattern copy here, so
|
|
141
|
+
// the hook and the PROJECT_IMPORTANT renderer can never drift apart. File/path
|
|
142
|
+
// classification and Bash command shapes stay in this hook.
|
|
143
|
+
// Leak-safety: the scanner returns labels only — never a value, excerpt, or
|
|
144
|
+
// hash — so blocked-prompt previews can no longer print a value's truncated
|
|
145
|
+
// sha256; the redacted block message says the value is withheld instead.
|
|
146
|
+
const SCANNER_PATH = process.env.SCRIPT_PATH || '';
|
|
147
|
+
let scannerModule = null;
|
|
148
|
+
let scannerTried = false;
|
|
149
|
+
async function loadScanner() {
|
|
150
|
+
if (scannerTried) return scannerModule;
|
|
151
|
+
scannerTried = true;
|
|
152
|
+
try {
|
|
153
|
+
const { pathToFileURL } = require('url');
|
|
154
|
+
scannerModule = await import(pathToFileURL(SCANNER_PATH).href);
|
|
155
|
+
} catch {
|
|
156
|
+
scannerModule = null;
|
|
136
157
|
}
|
|
137
|
-
return
|
|
158
|
+
return scannerModule;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// The gate is fail-closed on detection AND on scanner availability: a missing
|
|
162
|
+
// or crashing scanner must never silently bypass a channel that could carry a
|
|
163
|
+
// secret (the module is shipped by ukit-runtime-scripts, which the manifest
|
|
164
|
+
// declares this hook requires). Same redacted block shape as a detection.
|
|
165
|
+
function blockScannerUnavailable() {
|
|
166
|
+
process.stderr.write(
|
|
167
|
+
'BLOCKED (sensitive data gate unavailable): the shared scanner module '
|
|
168
|
+
+ 'ukit/runtime/sensitive-value-scanner.mjs is missing or failed to load, '
|
|
169
|
+
+ 'so this payload cannot be proven clean. Run `ukit install` to restore it.\n',
|
|
170
|
+
);
|
|
171
|
+
process.stdout.write(`${JSON.stringify({
|
|
172
|
+
systemMessage: 'UKit blocked this call: the sensitive-data scanner module is unavailable (fail-closed). Run `ukit install`.',
|
|
173
|
+
})}\n`);
|
|
174
|
+
process.exit(2);
|
|
138
175
|
}
|
|
139
176
|
|
|
140
177
|
// --- secret file classification ---
|
|
@@ -238,7 +275,8 @@ if (event === 'UserPromptSubmit') {
|
|
|
238
275
|
process.exit(0);
|
|
239
276
|
}
|
|
240
277
|
|
|
241
|
-
// --- collect findings ---
|
|
278
|
+
// --- collect findings (async: the value scan delegates to the shared module) ---
|
|
279
|
+
void (async () => {
|
|
242
280
|
const findings = [];
|
|
243
281
|
|
|
244
282
|
for (const filePath of filePathChannels) {
|
|
@@ -248,14 +286,36 @@ for (const filePath of filePathChannels) {
|
|
|
248
286
|
}
|
|
249
287
|
}
|
|
250
288
|
|
|
251
|
-
|
|
289
|
+
// The value scan needs the shared scanner module only when a text/bash
|
|
290
|
+
// channel exists to scan; a pure secret-file Read verdict never depends on it.
|
|
291
|
+
const needsScanner = textChannels.length > 0 || bashCommand;
|
|
292
|
+
let scanner = null;
|
|
293
|
+
if (needsScanner) {
|
|
294
|
+
scanner = await loadScanner();
|
|
295
|
+
if (!scanner || typeof scanner.scanText !== 'function') {
|
|
296
|
+
blockScannerUnavailable();
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const gateEnabled = scanner ? scanner.isSensitiveDataGateEnabled(config) !== false : true;
|
|
300
|
+
const allowlistHashes = scanner ? scanner.loadSensitiveAllowlist({ security: { allowlist } }) : [];
|
|
301
|
+
|
|
302
|
+
function scanTextLabels(text) {
|
|
303
|
+
let verdict;
|
|
304
|
+
try {
|
|
305
|
+
verdict = scanner.scanText(text, { allowlistHashes, gateEnabled });
|
|
306
|
+
} catch {
|
|
307
|
+
blockScannerUnavailable();
|
|
308
|
+
}
|
|
309
|
+
return verdict && Array.isArray(verdict.labels) ? verdict.labels : [];
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const seenLabels = new Set();
|
|
252
313
|
for (const { channel, text } of textChannels) {
|
|
253
|
-
for (const
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
findings.push({ label: `${finding.label} in ${channel}`, value: finding.value });
|
|
314
|
+
for (const label of scanTextLabels(text)) {
|
|
315
|
+
const key = `${channel}:${label}`;
|
|
316
|
+
if (seenLabels.has(key)) continue;
|
|
317
|
+
seenLabels.add(key);
|
|
318
|
+
findings.push({ label: `${label} in ${channel}` });
|
|
259
319
|
}
|
|
260
320
|
}
|
|
261
321
|
|
|
@@ -268,12 +328,11 @@ if (bashCommand) {
|
|
|
268
328
|
if (allowedValueHashes.has(sha256(finding.value))) continue;
|
|
269
329
|
findings.push({ label: `${finding.label} in bash command`, value: finding.value });
|
|
270
330
|
}
|
|
271
|
-
for (const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
findings.push({ label: `${finding.label} in bash command`, value: finding.value });
|
|
331
|
+
for (const label of scanTextLabels(bashCommand)) {
|
|
332
|
+
const key = `bash:${label}`;
|
|
333
|
+
if (seenLabels.has(key)) continue;
|
|
334
|
+
seenLabels.add(key);
|
|
335
|
+
findings.push({ label: `${label} in bash command` });
|
|
277
336
|
}
|
|
278
337
|
}
|
|
279
338
|
|
|
@@ -289,9 +348,17 @@ if (findings.length === 0) {
|
|
|
289
348
|
// ("Waiting for N background agents"). These envelopes flow with a redacted
|
|
290
349
|
// advisory: labels + counts only, never values. Everything a human types —
|
|
291
350
|
// and every other event/channel — stays fail-closed below.
|
|
292
|
-
const MACHINE_ENVELOPE_RE =
|
|
351
|
+
const MACHINE_ENVELOPE_RE = /^(?:task-notification|system-reminder|cross-session-message)$/i;
|
|
352
|
+
function isCompleteMachineEnvelope(text) {
|
|
353
|
+
const trimmed = String(text || '').trim();
|
|
354
|
+
const opening = /^<([a-z-]+)(?:\s[^>]*)?>/i.exec(trimmed);
|
|
355
|
+
if (!opening || !MACHINE_ENVELOPE_RE.test(opening[1])) return false;
|
|
356
|
+
const tag = opening[1];
|
|
357
|
+
const closing = new RegExp(`</${tag}>$`, 'i');
|
|
358
|
+
return closing.test(trimmed);
|
|
359
|
+
}
|
|
293
360
|
const promptText = textChannels.length > 0 ? textChannels[0].text : null;
|
|
294
|
-
if (event === 'UserPromptSubmit' && promptText &&
|
|
361
|
+
if (event === 'UserPromptSubmit' && promptText && isCompleteMachineEnvelope(promptText)) {
|
|
295
362
|
const labels = [...new Set(findings.map((f) => f.label))];
|
|
296
363
|
process.stdout.write(`${JSON.stringify({
|
|
297
364
|
systemMessage: `UKit sensitive-data gate: ${findings.length} secret-shaped value(s) inside a machine-generated notification (${labels.slice(0, 3).join('; ')}${labels.length > 3 ? '; …' : ''}). Delivered without blocking — treat as fixture/log plumbing, not a typed secret. Never repeat the values in your reply.`,
|
|
@@ -300,10 +367,10 @@ if (event === 'UserPromptSubmit' && promptText && MACHINE_ENVELOPE_RE.test(promp
|
|
|
300
367
|
}
|
|
301
368
|
|
|
302
369
|
// --- block message: redacted previews only, never the secret itself ---
|
|
370
|
+
// The shared scanner returns labels only, so value findings can no longer show
|
|
371
|
+
// a truncated sha256 — the value stays withheld entirely (leak-safe contract).
|
|
303
372
|
function describeFinding(finding) {
|
|
304
|
-
const where = finding.preview
|
|
305
|
-
? `${finding.preview}`
|
|
306
|
-
: `${String(finding.value).slice(0, 3)}…(${String(finding.value).length} chars, sha256 ${sha256(String(finding.value)).slice(0, 12)}…)`;
|
|
373
|
+
const where = finding.preview ? `${finding.preview}` : '(value withheld)';
|
|
307
374
|
return ` - ${finding.label}: ${where}`;
|
|
308
375
|
}
|
|
309
376
|
|
|
@@ -327,6 +394,7 @@ process.stdout.write(`${JSON.stringify({
|
|
|
327
394
|
systemMessage: `UKit blocked this call: ${findings.length} potential secret(s) detected (${findings.map((f) => f.label).slice(0, 3).join('; ')}${findings.length > 3 ? '; …' : ''}). Redact the value, allowlist its sha256, or ask the user to decide.`,
|
|
328
395
|
})}\n`);
|
|
329
396
|
process.exit(2);
|
|
397
|
+
})();
|
|
330
398
|
NODE
|
|
331
399
|
|
|
332
400
|
exit $?
|
|
@@ -5,17 +5,43 @@
|
|
|
5
5
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
6
6
|
# shellcheck source=/dev/null
|
|
7
7
|
if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
|
|
8
|
+
# TASK-019: arm advisory latency telemetry; the finish row is written by the
|
|
9
|
+
# shared cleanup path before the staged payload is removed. A missing
|
|
10
|
+
# runtime (pre-install tree) simply leaves telemetry off.
|
|
11
|
+
source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
|
|
8
12
|
trap ukit_cleanup_hook_input EXIT
|
|
9
13
|
# Bounded stdin (H01): stage before anything reads it; the shell var is capped,
|
|
10
14
|
# env passes only the staged-file path — never the payload.
|
|
11
15
|
ukit_stage_hook_input 2097152 truncate || exit 0
|
|
12
16
|
else
|
|
13
|
-
# Runtime helper missing (pre-install tree): bounded
|
|
14
|
-
#
|
|
17
|
+
# Runtime helper missing (pre-install tree): the SAME bounded staging,
|
|
18
|
+
# inline - `cat >/dev/null` used to block forever on a producer that never
|
|
19
|
+
# closes the pipe (R4.5). Read cap+1 in the background, bound the wait.
|
|
15
20
|
UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
21
|
+
if [ -e /dev/fd/0 ]; then
|
|
22
|
+
exec 8<&0
|
|
23
|
+
head -c 2097153 <&8 > "$UKIT_INPUT_FILE" 2>/dev/null &
|
|
24
|
+
else
|
|
25
|
+
head -c 2097153 > "$UKIT_INPUT_FILE" 2>/dev/null &
|
|
26
|
+
fi
|
|
27
|
+
__ukit_reader=$!
|
|
28
|
+
__ukit_stage_ms="${UKIT_HOOK_STAGE_MS:-2000}"
|
|
29
|
+
case "$__ukit_stage_ms" in
|
|
30
|
+
''|*[!0-9]*) __ukit_stage_ms=2000 ;;
|
|
31
|
+
esac
|
|
32
|
+
[ "$__ukit_stage_ms" -gt 0 ] 2>/dev/null || __ukit_stage_ms=2000
|
|
33
|
+
printf -v __ukit_stage_s '%d.%03d' $((__ukit_stage_ms / 1000)) $((__ukit_stage_ms % 1000))
|
|
34
|
+
( sleep "$__ukit_stage_s" 2>/dev/null; kill -9 "$__ukit_reader" 2>/dev/null ) <&- >/dev/null 2>&1 &
|
|
35
|
+
__ukit_waiter=$!
|
|
36
|
+
wait "$__ukit_reader" 2>/dev/null
|
|
37
|
+
__ukit_stage_rc=$?
|
|
38
|
+
kill "$__ukit_waiter" 2>/dev/null
|
|
39
|
+
wait "$__ukit_waiter" 2>/dev/null
|
|
40
|
+
exec 8<&-
|
|
41
|
+
__ukit_size="$(wc -c < "$UKIT_INPUT_FILE" 2>/dev/null | tr -d '[:space:]')"
|
|
42
|
+
# A bounded cut (rc != 0) leaves a short payload that the JSON consumer
|
|
43
|
+
# already degrades on - the shared helper's truncate posture (R4.5).
|
|
44
|
+
if [ "$__ukit_size" -gt 2097152 ]; then
|
|
19
45
|
truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
|
|
20
46
|
fi
|
|
21
47
|
trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
|
|
@@ -5,16 +5,42 @@
|
|
|
5
5
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
6
6
|
# shellcheck source=/dev/null
|
|
7
7
|
if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
|
|
8
|
+
# TASK-019: arm advisory latency telemetry; the finish row is written by the
|
|
9
|
+
# shared cleanup path before the staged payload is removed. A missing
|
|
10
|
+
# runtime (pre-install tree) simply leaves telemetry off.
|
|
11
|
+
source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
|
|
8
12
|
trap ukit_cleanup_hook_input EXIT
|
|
9
13
|
# Bounded stdin (H01): stage before anything reads it; the shell var is capped.
|
|
10
14
|
ukit_stage_hook_input 2097152 truncate || exit 0
|
|
11
15
|
else
|
|
12
|
-
# Runtime helper missing (pre-install tree): bounded
|
|
13
|
-
#
|
|
16
|
+
# Runtime helper missing (pre-install tree): the SAME bounded staging,
|
|
17
|
+
# inline - `cat >/dev/null` used to block forever on a producer that never
|
|
18
|
+
# closes the pipe (R4.5). Read cap+1 in the background, bound the wait.
|
|
14
19
|
UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
if [ -e /dev/fd/0 ]; then
|
|
21
|
+
exec 8<&0
|
|
22
|
+
head -c 2097153 <&8 > "$UKIT_INPUT_FILE" 2>/dev/null &
|
|
23
|
+
else
|
|
24
|
+
head -c 2097153 > "$UKIT_INPUT_FILE" 2>/dev/null &
|
|
25
|
+
fi
|
|
26
|
+
__ukit_reader=$!
|
|
27
|
+
__ukit_stage_ms="${UKIT_HOOK_STAGE_MS:-2000}"
|
|
28
|
+
case "$__ukit_stage_ms" in
|
|
29
|
+
''|*[!0-9]*) __ukit_stage_ms=2000 ;;
|
|
30
|
+
esac
|
|
31
|
+
[ "$__ukit_stage_ms" -gt 0 ] 2>/dev/null || __ukit_stage_ms=2000
|
|
32
|
+
printf -v __ukit_stage_s '%d.%03d' $((__ukit_stage_ms / 1000)) $((__ukit_stage_ms % 1000))
|
|
33
|
+
( sleep "$__ukit_stage_s" 2>/dev/null; kill -9 "$__ukit_reader" 2>/dev/null ) <&- >/dev/null 2>&1 &
|
|
34
|
+
__ukit_waiter=$!
|
|
35
|
+
wait "$__ukit_reader" 2>/dev/null
|
|
36
|
+
__ukit_stage_rc=$?
|
|
37
|
+
kill "$__ukit_waiter" 2>/dev/null
|
|
38
|
+
wait "$__ukit_waiter" 2>/dev/null
|
|
39
|
+
exec 8<&-
|
|
40
|
+
__ukit_size="$(wc -c < "$UKIT_INPUT_FILE" 2>/dev/null | tr -d '[:space:]')"
|
|
41
|
+
# A bounded cut (rc != 0) leaves a short payload that the JSON consumer
|
|
42
|
+
# already degrades on - the shared helper's truncate posture (R4.5).
|
|
43
|
+
if [ "$__ukit_size" -gt 2097152 ]; then
|
|
18
44
|
truncate -s 2097152 "$UKIT_INPUT_FILE" 2>/dev/null
|
|
19
45
|
fi
|
|
20
46
|
trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
|
|
@@ -5,33 +5,35 @@
|
|
|
5
5
|
# The Quality Gate lets handoff runs go on for a while, but a wedged task
|
|
6
6
|
# that never reports a blocker still burns the rest of the pipeline. This
|
|
7
7
|
# hook measures wall-clock per in_progress task against approved budgets
|
|
8
|
-
# (S 8/15, M 15/30, L 25/45 minutes by default)
|
|
9
|
-
# - soft overrun: emits a VISIBLE "checkpoint milestone now + split remainder"
|
|
10
|
-
# advisory on Stop (systemMessage), never blocks.
|
|
11
|
-
# - hard overrun (split policy, default): emits Stop decision=block with a
|
|
12
|
-
# visible split instruction naming the follow-up
|
|
13
|
-
# TASK-<id>-b; keeps the cycle running instead of letting
|
|
14
|
-
# the agent stop silently mid-budget.
|
|
15
|
-
# - hard overrun (pause policy, selectable in config): emits a pause
|
|
16
|
-
# advisory on Stop, never blocks.
|
|
8
|
+
# (S 8/15, M 15/30, L 25/45 minutes by default).
|
|
17
9
|
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
10
|
+
# TASK-025 (PLAN §2 H17): the watchdog no longer owns any Stop decision. A single
|
|
11
|
+
# coordinator (.claude/ukit/runtime/stop-coordinator.mjs, registered once on Stop
|
|
12
|
+
# through completion-gate.sh) evaluates BOTH the completion policy and this
|
|
13
|
+
# watchdog's budget policy and emits at most one continuation decision — two
|
|
14
|
+
# independent blockers used to double-block the same Stop.
|
|
15
|
+
#
|
|
16
|
+
# Dispatch on hook_event_name:
|
|
17
|
+
# Stop → thin delegate: hand the staged payload to the stop coordinator
|
|
18
|
+
# and pass its single decision through. Kept for legacy settings
|
|
19
|
+
# that still register this script on Stop; the coordinator's
|
|
20
|
+
# once-per-stop dedupe keeps a double-registered Stop inert.
|
|
21
|
+
# PostToolUse → advisory only (systemMessage); NEVER emits decision. Re-evaluates
|
|
22
|
+
# so the runtime state stays warm for the next Stop.
|
|
23
|
+
# anything else → exit 0 silently.
|
|
20
24
|
#
|
|
21
25
|
# Everything fails open and stays inside the 4s hook-chain budget:
|
|
22
26
|
# - HOOK_DEADLINE_MS=3000 self-kill via setTimeout(...).unref()
|
|
23
27
|
# - all I/O is async (sync reads on a stalled mount would block the timer)
|
|
24
28
|
# - process.exit(0) on every path; never throws.
|
|
25
|
-
#
|
|
26
|
-
# Dispatch on hook_event_name:
|
|
27
|
-
# Stop → evaluate and may emit decision=block; never emits decision
|
|
28
|
-
# unless the trip crosses hardMin AND hardPolicy='split'.
|
|
29
|
-
# PostToolUse → advisory only (systemMessage); NEVER emits decision.
|
|
30
|
-
# anything else → exit 0 silently.
|
|
31
29
|
|
|
32
30
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
33
31
|
# shellcheck source=/dev/null
|
|
34
32
|
if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
|
|
33
|
+
# TASK-019: arm advisory latency telemetry; the finish row is written by the
|
|
34
|
+
# shared cleanup path before the staged payload is removed. A missing
|
|
35
|
+
# runtime (pre-install tree) simply leaves telemetry off.
|
|
36
|
+
source "$SCRIPT_DIR/../ukit/runtime/hook-telemetry.sh" 2>/dev/null || true
|
|
35
37
|
trap ukit_cleanup_hook_input EXIT
|
|
36
38
|
# Bounded stdin (H01): the 64 KiB cap is preserved, but the payload now travels
|
|
37
39
|
# as a staged file path — never through the exec environment, so an oversized
|
|
@@ -40,12 +42,35 @@ if source "$SCRIPT_DIR/../ukit/runtime/hook-input.sh" 2>/dev/null; then
|
|
|
40
42
|
# degrade exit 0 (documented posture).
|
|
41
43
|
ukit_stage_hook_input 65536 truncate || exit 0
|
|
42
44
|
else
|
|
43
|
-
# Runtime helper missing (pre-install tree): bounded inline staging keeps the
|
|
44
45
|
# 64 KiB cap and the advisory degrade path alive.
|
|
46
|
+
# Runtime helper missing (pre-install tree): the SAME bounded staging,
|
|
47
|
+
# inline - `cat >/dev/null` used to block forever on a producer that never
|
|
48
|
+
# closes the pipe (R4.5). Read cap+1 in the background, bound the wait.
|
|
45
49
|
UKIT_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-hook-in.XXXXXX")"
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
50
|
+
if [ -e /dev/fd/0 ]; then
|
|
51
|
+
exec 8<&0
|
|
52
|
+
head -c 65537 <&8 > "$UKIT_INPUT_FILE" 2>/dev/null &
|
|
53
|
+
else
|
|
54
|
+
head -c 65537 > "$UKIT_INPUT_FILE" 2>/dev/null &
|
|
55
|
+
fi
|
|
56
|
+
__ukit_reader=$!
|
|
57
|
+
__ukit_stage_ms="${UKIT_HOOK_STAGE_MS:-2000}"
|
|
58
|
+
case "$__ukit_stage_ms" in
|
|
59
|
+
''|*[!0-9]*) __ukit_stage_ms=2000 ;;
|
|
60
|
+
esac
|
|
61
|
+
[ "$__ukit_stage_ms" -gt 0 ] 2>/dev/null || __ukit_stage_ms=2000
|
|
62
|
+
printf -v __ukit_stage_s '%d.%03d' $((__ukit_stage_ms / 1000)) $((__ukit_stage_ms % 1000))
|
|
63
|
+
( sleep "$__ukit_stage_s" 2>/dev/null; kill -9 "$__ukit_reader" 2>/dev/null ) <&- >/dev/null 2>&1 &
|
|
64
|
+
__ukit_waiter=$!
|
|
65
|
+
wait "$__ukit_reader" 2>/dev/null
|
|
66
|
+
__ukit_stage_rc=$?
|
|
67
|
+
kill "$__ukit_waiter" 2>/dev/null
|
|
68
|
+
wait "$__ukit_waiter" 2>/dev/null
|
|
69
|
+
exec 8<&-
|
|
70
|
+
__ukit_size="$(wc -c < "$UKIT_INPUT_FILE" 2>/dev/null | tr -d '[:space:]')"
|
|
71
|
+
# A bounded cut (rc != 0) leaves a short payload that the JSON consumer
|
|
72
|
+
# already degrades on - the shared helper's truncate posture (R4.5).
|
|
73
|
+
if [ "$__ukit_size" -gt 65536 ]; then
|
|
49
74
|
truncate -s 65536 "$UKIT_INPUT_FILE" 2>/dev/null
|
|
50
75
|
fi
|
|
51
76
|
trap '[ -n "$UKIT_INPUT_FILE" ] && rm -f "$UKIT_INPUT_FILE"' EXIT
|
|
@@ -56,7 +81,13 @@ INPUT_FILE="$UKIT_INPUT_FILE" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE' || true
|
|
|
56
81
|
'use strict';
|
|
57
82
|
|
|
58
83
|
const fsp = require('fs/promises');
|
|
84
|
+
// TASK-024 fix (regression from TASK-014's H01 bounded-staging refactor): the staged
|
|
85
|
+
// payload is read with fs.readFileSync, but the `fs` require was dropped — every
|
|
86
|
+
// payload read then threw ReferenceError, was swallowed by the try/catch, and the
|
|
87
|
+
// hook silently became a no-op (empty payload → irrelevant event → exit 0).
|
|
88
|
+
const fs = require('fs');
|
|
59
89
|
const path = require('path');
|
|
90
|
+
const { spawn } = require('child_process');
|
|
60
91
|
const { pathToFileURL } = require('url');
|
|
61
92
|
|
|
62
93
|
// Wall-clock watchdog. Anything that stalls here must end in clean exit 0 —
|
|
@@ -79,7 +110,6 @@ try { rawInput = fs.readFileSync(process.env.INPUT_FILE || '', 'utf8'); } catch
|
|
|
79
110
|
})();
|
|
80
111
|
|
|
81
112
|
const hookEvent = typeof payload.hook_event_name === 'string' ? payload.hook_event_name : '';
|
|
82
|
-
const toolName = typeof payload.tool_name === 'string' ? payload.tool_name : '';
|
|
83
113
|
const projectRoot = process.env.PROJECT_ROOT || process.cwd();
|
|
84
114
|
|
|
85
115
|
// Silent degrade for non-relevant events — same posture as every other
|
|
@@ -88,6 +118,43 @@ try { rawInput = fs.readFileSync(process.env.INPUT_FILE || '', 'utf8'); } catch
|
|
|
88
118
|
process.exit(0);
|
|
89
119
|
}
|
|
90
120
|
|
|
121
|
+
// TASK-025: Stop is a thin delegate — the stop coordinator owns every Stop
|
|
122
|
+
// decision (completion policy + this watchdog's budget policy, merged into at
|
|
123
|
+
// most one continuation decision). Fail-open: a missing coordinator (pre-install
|
|
124
|
+
// tree) just means no watchdog Stop evaluation, as before the delegate existed.
|
|
125
|
+
if (hookEvent === 'Stop') {
|
|
126
|
+
const coordinatorPath = path.join(projectRoot, '.claude', 'ukit', 'runtime', 'stop-coordinator.mjs');
|
|
127
|
+
try {
|
|
128
|
+
await fsp.access(coordinatorPath);
|
|
129
|
+
} catch {
|
|
130
|
+
process.exit(0);
|
|
131
|
+
}
|
|
132
|
+
const delegateInput = rawInput;
|
|
133
|
+
const child = spawn(process.execPath, [coordinatorPath, '--evaluate-stop'], {
|
|
134
|
+
cwd: projectRoot,
|
|
135
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: projectRoot },
|
|
136
|
+
stdio: ['pipe', 'pipe', 'inherit'],
|
|
137
|
+
});
|
|
138
|
+
let delegatedOutput = '';
|
|
139
|
+
const killTimer = setTimeout(() => {
|
|
140
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
141
|
+
}, HOOK_DEADLINE_MS);
|
|
142
|
+
if (typeof killTimer.unref === 'function') killTimer.unref();
|
|
143
|
+
child.stdout.on('data', (chunk) => { delegatedOutput += chunk; });
|
|
144
|
+
child.stdin.on('error', () => {}); // a stub coordinator that never reads stdin must not crash us
|
|
145
|
+
child.on('error', () => {
|
|
146
|
+
try { process.exit(0); } catch {}
|
|
147
|
+
});
|
|
148
|
+
child.on('close', () => {
|
|
149
|
+
try {
|
|
150
|
+
if (delegatedOutput.trim()) process.stdout.write(delegatedOutput);
|
|
151
|
+
} catch {}
|
|
152
|
+
process.exit(0);
|
|
153
|
+
});
|
|
154
|
+
child.stdin.end(delegateInput);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
91
158
|
const runtimePath = path.join(projectRoot, '.claude', 'ukit', 'runtime', 'task-watchdog.mjs');
|
|
92
159
|
// Async existence check — sync fs here would violate the all-async invariant
|
|
93
160
|
// (a stalled mount would block the loop and the 3s self-kill could not fire).
|
|
@@ -121,112 +188,30 @@ try { rawInput = fs.readFileSync(process.env.INPUT_FILE || '', 'utf8'); } catch
|
|
|
121
188
|
// PostToolUse: advisory only. NEVER emit decision — that would block the
|
|
122
189
|
// in-flight Edit and stall the run. We still re-evaluate so the runtime
|
|
123
190
|
// state stays warm and the next Stop sees an up-to-date view.
|
|
124
|
-
if (hookEvent === 'PostToolUse') {
|
|
125
|
-
const now = Date.now();
|
|
126
|
-
const evals = runtime.evaluateBudgets({ tasks, state, now, config });
|
|
127
|
-
const advisories = evals
|
|
128
|
-
.filter((r) => r.phase !== 'ok')
|
|
129
|
-
.map((r) => {
|
|
130
|
-
const task = tasks.find((t) => t.id === r.id);
|
|
131
|
-
if (!task) return null;
|
|
132
|
-
if (r.phase === 'hard') {
|
|
133
|
-
const blocks = Number(state.hardBlocks[r.id] || 0);
|
|
134
|
-
if (blocks >= (runtime.HARD_BLOCK_CAP || 2)) {
|
|
135
|
-
return runtime.describeDegraded({ id: r.id, hardBlocks: blocks });
|
|
136
|
-
}
|
|
137
|
-
return runtime.describeHardAdvisory(r, r.id);
|
|
138
|
-
}
|
|
139
|
-
return runtime.describeSoft(r, r.id);
|
|
140
|
-
})
|
|
141
|
-
.filter(Boolean);
|
|
142
|
-
|
|
143
|
-
if (advisories.length > 0) {
|
|
144
|
-
const message = advisories.join('\n');
|
|
145
|
-
try {
|
|
146
|
-
process.stdout.write(`${JSON.stringify({ systemMessage: message })}\n`);
|
|
147
|
-
} catch {}
|
|
148
|
-
}
|
|
149
|
-
process.exit(0);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// Stop: full evaluation, may emit decision=block under split policy.
|
|
153
191
|
const now = Date.now();
|
|
154
|
-
for (const task of tasks) {
|
|
155
|
-
try {
|
|
156
|
-
await runtime.ensureFirstSeen(state, task.id, now);
|
|
157
|
-
} catch {}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
192
|
const evals = runtime.evaluateBudgets({ tasks, state, now, config });
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
} catch {}
|
|
180
|
-
}
|
|
181
|
-
process.exit(0);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// At least one hard-trip. Apply policy.
|
|
185
|
-
const hardPolicy = String(config.taskBudgets?.hardPolicy || 'split');
|
|
186
|
-
const firstHard = hardResults[0];
|
|
187
|
-
const blocks = Number(state.hardBlocks[firstHard.id] || 0);
|
|
188
|
-
|
|
189
|
-
if (hardPolicy !== 'split') {
|
|
190
|
-
// pause (or anything non-split) → advisory only, never block.
|
|
191
|
-
const advisory = hardResults
|
|
192
|
-
.map((r) => runtime.describePause({ id: r.id, result: r }))
|
|
193
|
-
.join('\n');
|
|
194
|
-
try {
|
|
195
|
-
process.stdout.write(`${JSON.stringify({ systemMessage: advisory })}\n`);
|
|
196
|
-
} catch {}
|
|
197
|
-
process.exit(0);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// split policy + hard-trip — but the per-task block cap has already been
|
|
201
|
-
// hit. Degrade to advisory so the orchestrator can hand back to the user.
|
|
202
|
-
if (blocks >= (runtime.HARD_BLOCK_CAP || 2)) {
|
|
203
|
-
await runtime.bumpHardBlocks(state, firstHard.id);
|
|
204
|
-
await runtime.writeState(statePath, state);
|
|
205
|
-
const advisory = runtime.describeDegraded({ id: firstHard.id, hardBlocks: blocks + 1 });
|
|
193
|
+
const advisories = evals
|
|
194
|
+
.filter((r) => r.phase !== 'ok')
|
|
195
|
+
.map((r) => {
|
|
196
|
+
const task = tasks.find((t) => t.id === r.id);
|
|
197
|
+
if (!task) return null;
|
|
198
|
+
if (r.phase === 'hard') {
|
|
199
|
+
const blocks = Number(state.hardBlocks[r.id] || 0);
|
|
200
|
+
if (blocks >= (runtime.HARD_BLOCK_CAP || 2)) {
|
|
201
|
+
return runtime.describeDegraded({ id: r.id, hardBlocks: blocks });
|
|
202
|
+
}
|
|
203
|
+
return runtime.describeHardAdvisory(r, r.id);
|
|
204
|
+
}
|
|
205
|
+
return runtime.describeSoft(r, r.id);
|
|
206
|
+
})
|
|
207
|
+
.filter(Boolean);
|
|
208
|
+
|
|
209
|
+
if (advisories.length > 0) {
|
|
210
|
+
const message = advisories.join('\n');
|
|
206
211
|
try {
|
|
207
|
-
process.stdout.write(`${JSON.stringify({ systemMessage:
|
|
212
|
+
process.stdout.write(`${JSON.stringify({ systemMessage: message })}\n`);
|
|
208
213
|
} catch {}
|
|
209
|
-
process.exit(0);
|
|
210
214
|
}
|
|
211
|
-
|
|
212
|
-
// Hard trip + split policy + under the cap → emit Stop decision=block
|
|
213
|
-
// with the visible split instruction. This is the "auto-split and continue"
|
|
214
|
-
// path: blocking the stop IS the continue mechanism — the executor / orchestrator
|
|
215
|
-
// performs the actual TASK-<id>-b cut on the next turn following the reason.
|
|
216
|
-
const newBlocks = await runtime.bumpHardBlocks(state, firstHard.id);
|
|
217
|
-
await runtime.writeState(statePath, state);
|
|
218
|
-
|
|
219
|
-
const task = tasks.find((t) => t.id === firstHard.id) || { id: firstHard.id };
|
|
220
|
-
const lastGreen = runtime.pickLastGreen(task);
|
|
221
|
-
const reason = runtime.describeSplitReason({
|
|
222
|
-
id: firstHard.id,
|
|
223
|
-
result: firstHard,
|
|
224
|
-
hardBlocks: newBlocks,
|
|
225
|
-
lastGreen,
|
|
226
|
-
});
|
|
227
|
-
try {
|
|
228
|
-
process.stdout.write(`${JSON.stringify({ decision: 'block', reason })}\n`);
|
|
229
|
-
} catch {}
|
|
230
215
|
process.exit(0);
|
|
231
216
|
})().catch(() => {
|
|
232
217
|
// Fail-open: never let an exception kill the hook.
|