@ngockhoale/ukit 3.4.21 → 3.4.22
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 +12 -0
- package/manifests/documentation.yaml +11 -0
- package/manifests/platform.full.yaml +13 -0
- package/package.json +1 -1
- package/src/core/hookChainDoctor.js +39 -3
- package/template_project/.claude/.gitattributes +2 -0
- package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +83 -6
- package/template_project/.claude/ukit/runtime/hook-process.mjs +120 -4
- package/template_project/.omp/hooks/pre/ukit-bridge.js +12 -0
- package/template_project/docs/UKIT_INTERNALS.md +14 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to UKit are documented here.
|
|
4
4
|
|
|
5
|
+
## 3.4.22 - 2026-10-07
|
|
6
|
+
|
|
7
|
+
**Windows: `.sh` hooks no longer deny every Edit/Write/Bash.**
|
|
8
|
+
- `.sh` hooks are now launched through Git for Windows bash: `UKIT_BASH_BIN` → `CLAUDE_CODE_GIT_BASH_PATH` →
|
|
9
|
+
default Git install paths → absolute `PATH` entries. WSL's `bash.exe` and a cwd `bash.exe` are never used.
|
|
10
|
+
- On Windows a `.sh` hook whose bash cannot be found or started is reported as `spawn-unavailable` and warned
|
|
11
|
+
instead of denying every Edit/Write/Bash. POSIX behaviour is unchanged; timeouts, real blocks, missing gate
|
|
12
|
+
files and `block-dangerous.sh` stay fail-closed.
|
|
13
|
+
- `ukit doctor` flags Windows hook launch failures.
|
|
14
|
+
- `*.sh` is pinned to LF (repo `.gitattributes` and a shipped `.claude/.gitattributes`) so a CRLF checkout cannot
|
|
15
|
+
break hook scripts. Troubleshooting added to `docs/INSTALL.md`.
|
|
16
|
+
|
|
5
17
|
## 3.4.21 - 2026-10-07
|
|
6
18
|
|
|
7
19
|
**Hook runtime no longer burns CPU/disk on every tool call** (five measured bugs, C95).
|
|
@@ -707,6 +707,17 @@ entries:
|
|
|
707
707
|
validation: [manual]
|
|
708
708
|
archive_policy: never
|
|
709
709
|
notes: non-.md entry — exists for FR-002 manifest consistency
|
|
710
|
+
- id: tpl-claude-gitattributes
|
|
711
|
+
path: template_project/.claude/.gitattributes
|
|
712
|
+
class: canonical
|
|
713
|
+
audience: [maintainer]
|
|
714
|
+
owner: product
|
|
715
|
+
source_of_truth: template_project/.claude/.gitattributes
|
|
716
|
+
merge_strategy: skip
|
|
717
|
+
load_policy: on-demand
|
|
718
|
+
validation: [manual]
|
|
719
|
+
archive_policy: never
|
|
720
|
+
notes: non-.md entry — keeps hook *.sh LF on Windows autocrlf checkouts; exists for FR-002 manifest consistency
|
|
710
721
|
|
|
711
722
|
# ── repo mirrors (derived from template_project/) ───────────────────────────────
|
|
712
723
|
- id: mirror-claude-dir
|
|
@@ -1511,6 +1511,19 @@ items:
|
|
|
1511
1511
|
packs:
|
|
1512
1512
|
- core
|
|
1513
1513
|
|
|
1514
|
+
# Ships `*.sh text eol=lf` inside .claude/ only (never the user's root .gitattributes) so
|
|
1515
|
+
# a Windows autocrlf checkout cannot turn hook scripts into CRLF that bash rejects.
|
|
1516
|
+
- id: claude-gitattributes
|
|
1517
|
+
type: config
|
|
1518
|
+
sourceTemplate: .claude/.gitattributes
|
|
1519
|
+
targetPath: .claude/.gitattributes
|
|
1520
|
+
requires: []
|
|
1521
|
+
mergeStrategy: skip
|
|
1522
|
+
variables: []
|
|
1523
|
+
enabledByDefault: true
|
|
1524
|
+
packs:
|
|
1525
|
+
- core
|
|
1526
|
+
|
|
1514
1527
|
- id: hook-reset-compact-pressure
|
|
1515
1528
|
type: hook
|
|
1516
1529
|
sourceTemplate: .claude/hooks/reset-compact-pressure.sh
|
package/package.json
CHANGED
|
@@ -24,12 +24,31 @@ const FAILURE_OUTCOMES = new Set([
|
|
|
24
24
|
'output-overflow',
|
|
25
25
|
'error',
|
|
26
26
|
'signal',
|
|
27
|
+
'spawn-unavailable',
|
|
27
28
|
]);
|
|
28
29
|
|
|
29
30
|
// The pre-O6 remedy, kept verbatim: a failure with no gate evidence must read
|
|
30
31
|
// exactly as it did before escalation existed (SPEC §FR-012).
|
|
32
|
+
// SPEC §FR-007: launch failures only arise on win32 (the runner emits
|
|
33
|
+
// `spawn-unavailable` from its bash-launcher path), so there is one remedy.
|
|
34
|
+
const LAUNCH_REMEDY = 'Windows cannot execute .sh hooks directly — install Git for Windows so C:\\Program Files\\Git\\bin\\bash.exe exists, or set UKIT_BASH_BIN to the full path of bash.exe, then restart the session.';
|
|
35
|
+
|
|
36
|
+
// Legacy pre-fix win32 rows recorded a failed .sh launch as a plain `error`
|
|
37
|
+
// that finished almost instantly.
|
|
38
|
+
const LEGACY_LAUNCH_FAILURE_MAX_MS = 10;
|
|
39
|
+
|
|
31
40
|
const BASE_REMEDY = 'Inspect recent rows in .ukit/storage/cache/hook-latency/ for the failing scriptName/failureKind and fix or widen the gate budget.';
|
|
32
41
|
|
|
42
|
+
function isLaunchFailure(entry, platform) {
|
|
43
|
+
if (!entry || typeof entry !== 'object') return false;
|
|
44
|
+
if (typeof entry.scriptName !== 'string' || !entry.scriptName.toLowerCase().endsWith('.sh')) return false;
|
|
45
|
+
if (entry.failureKind === 'spawn-unavailable') return true;
|
|
46
|
+
return platform === 'win32'
|
|
47
|
+
&& entry.failureKind === 'error'
|
|
48
|
+
&& typeof entry.elapsedMs === 'number'
|
|
49
|
+
&& entry.elapsedMs < LEGACY_LAUNCH_FAILURE_MAX_MS;
|
|
50
|
+
}
|
|
51
|
+
|
|
33
52
|
function telemetryDirFor(projectRoot) {
|
|
34
53
|
return path.join(projectRoot, '.ukit', 'storage', 'cache', 'hook-latency');
|
|
35
54
|
}
|
|
@@ -78,7 +97,7 @@ async function tailLines(filePath, size) {
|
|
|
78
97
|
}
|
|
79
98
|
}
|
|
80
99
|
|
|
81
|
-
export async function inspectHookChainHealth({ projectRoot, now = Date.now() } = {}) {
|
|
100
|
+
export async function inspectHookChainHealth({ projectRoot, now = Date.now(), platform = process.platform } = {}) {
|
|
82
101
|
const label = 'hook-chain health';
|
|
83
102
|
|
|
84
103
|
// Never-fatal posture extends to malformed input: a missing projectRoot is
|
|
@@ -106,6 +125,7 @@ export async function inspectHookChainHealth({ projectRoot, now = Date.now() } =
|
|
|
106
125
|
// evidence shapes that prove it, collected in the same pass that counts
|
|
107
126
|
// outcomes so no second read of the rows is needed.
|
|
108
127
|
const gateFailures = new Set();
|
|
128
|
+
const launchFailures = new Set();
|
|
109
129
|
let budgetExhaustedChains = 0;
|
|
110
130
|
|
|
111
131
|
for (const file of files) {
|
|
@@ -130,6 +150,14 @@ export async function inspectHookChainHealth({ projectRoot, now = Date.now() } =
|
|
|
130
150
|
const outcome = typeof row.outcome === 'string' && row.outcome.length > 0 ? row.outcome : 'ok';
|
|
131
151
|
counts[outcome] = (counts[outcome] ?? 0) + 1;
|
|
132
152
|
|
|
153
|
+
// .sh launch failures are collected from EVERY row (an exit-code row can
|
|
154
|
+
// still carry one); they are a different signal from a gate that ran and failed.
|
|
155
|
+
if (Array.isArray(row.scripts)) {
|
|
156
|
+
for (const entry of row.scripts) {
|
|
157
|
+
if (isLaunchFailure(entry, platform)) launchFailures.add(entry.scriptName);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
133
161
|
// Escalation evidence is only ever read off a FAILING row — a gate that
|
|
134
162
|
// ran fine is not evidence of anything, and SPEC §FR-012 scopes the
|
|
135
163
|
// budget signal to a non-ok row ("a non-ok row ... escalates only when
|
|
@@ -155,6 +183,7 @@ export async function inspectHookChainHealth({ projectRoot, now = Date.now() } =
|
|
|
155
183
|
if (!entry || typeof entry !== 'object') continue;
|
|
156
184
|
if (entry.failClosed !== true) continue;
|
|
157
185
|
if (typeof entry.failureKind !== 'string' || entry.failureKind === 'ok') continue;
|
|
186
|
+
if (entry.failureKind === 'spawn-unavailable') continue; // reported as a launch failure
|
|
158
187
|
gateFailures.add(typeof entry.scriptName === 'string' ? entry.scriptName : 'unknown');
|
|
159
188
|
}
|
|
160
189
|
}
|
|
@@ -177,7 +206,7 @@ export async function inspectHookChainHealth({ projectRoot, now = Date.now() } =
|
|
|
177
206
|
.filter((key) => FAILURE_OUTCOMES.has(key) && counts[key] > 0)
|
|
178
207
|
.sort();
|
|
179
208
|
|
|
180
|
-
if (failureKinds.length === 0) {
|
|
209
|
+
if (failureKinds.length === 0 && launchFailures.size === 0) {
|
|
181
210
|
return {
|
|
182
211
|
label,
|
|
183
212
|
passed: true,
|
|
@@ -190,7 +219,9 @@ export async function inspectHookChainHealth({ projectRoot, now = Date.now() } =
|
|
|
190
219
|
}
|
|
191
220
|
|
|
192
221
|
const breakdown = failureKinds.map((kind) => `${kind}=${counts[kind]}`).join(', ');
|
|
193
|
-
const baseDetail =
|
|
222
|
+
const baseDetail = failureKinds.length > 0
|
|
223
|
+
? `${counts.rowsScanned} chain row(s) scanned — fail-closed outcomes: ${breakdown}`
|
|
224
|
+
: `${counts.rowsScanned} chain row(s) scanned`;
|
|
194
225
|
|
|
195
226
|
// O6 (SPEC §FR-012): escalate only when a failure actually landed ON a gate —
|
|
196
227
|
// a gate that merely ran is not evidence. The escalation is purely additive:
|
|
@@ -207,6 +238,11 @@ export async function inspectHookChainHealth({ projectRoot, now = Date.now() } =
|
|
|
207
238
|
`A fail-closed gate actually failed (${gateList.join(', ')}) — that is a real budget or path problem, not a slow advisory hook; widen the failing gate's budget or fix that step.`,
|
|
208
239
|
);
|
|
209
240
|
}
|
|
241
|
+
const launchList = [...launchFailures].sort();
|
|
242
|
+
if (launchList.length > 0) {
|
|
243
|
+
escalations.push(`.sh hook(s) could not be launched: ${launchList.join(', ')}`);
|
|
244
|
+
advices.push(LAUNCH_REMEDY);
|
|
245
|
+
}
|
|
210
246
|
if (budgetExhaustedChains > 0) {
|
|
211
247
|
escalations.push(`${budgetExhaustedChains} chain row(s) exhausted the total budget`);
|
|
212
248
|
advices.push(
|
|
@@ -6,7 +6,7 @@ import { pathToFileURL } from 'node:url';
|
|
|
6
6
|
// TASK-016: children run through the process-tree runner instead of bare
|
|
7
7
|
// spawnSync — a timed-out child is TERM→KILLed as a whole POSIX process group,
|
|
8
8
|
// so a bash wrapper can no longer orphan the Node grandchildren it spawned.
|
|
9
|
-
import { runHookProcess } from './hook-process.mjs';
|
|
9
|
+
import { runHookProcess, resolveHookCommand, SPAWN_UNAVAILABLE_CODES } from './hook-process.mjs';
|
|
10
10
|
// Repeated identical blocks (a model respawning an agent into the same refusal) get a
|
|
11
11
|
// "stop and report verbatim" directive appended. Advisory + fail-open; see block-breaker.mjs.
|
|
12
12
|
// Loaded dynamically so a partially-updated runtime (module missing) degrades to "no advice"
|
|
@@ -88,6 +88,7 @@ async function readStdinBounded() {
|
|
|
88
88
|
// 'signal' — the child died from a signal this runner did not send
|
|
89
89
|
// 'error' — the child never spawned (ENOENT and friends)
|
|
90
90
|
// 'budget-exhausted' — the script never ran: the chain total budget was spent
|
|
91
|
+
// 'spawn-unavailable' — win32 bash launcher not found or could not start the .sh script (no process ran); warned, never blocks
|
|
91
92
|
function chainFailureKind(processResult) {
|
|
92
93
|
switch (processResult?.failureKind) {
|
|
93
94
|
case 'overflow':
|
|
@@ -169,6 +170,18 @@ function parseScriptArg(arg) {
|
|
|
169
170
|
// 'timeout'. The deadline is enforced by Promise.race + AbortSignal; the
|
|
170
171
|
// rejection race keeps running in the background (in-proc code cannot be
|
|
171
172
|
// SIGKILLed) but its late return value is discarded.
|
|
173
|
+
// FR-004: warning for a step whose win32 bash launcher never started. Single
|
|
174
|
+
// Windows fixText — this classification is unreachable off win32 (`viaBash`).
|
|
175
|
+
const SPAWN_UNAVAILABLE_FIX =
|
|
176
|
+
'Fix: install Git for Windows (https://git-scm.com/download/win) or set UKIT_BASH_BIN to the full path of bash.exe.';
|
|
177
|
+
|
|
178
|
+
function spawnUnavailableWarning(scriptName, cause) {
|
|
179
|
+
const gateText = FAIL_CLOSED_SCRIPTS.has(scriptName)
|
|
180
|
+
? 'fail-closed gate NOT enforced'
|
|
181
|
+
: 'advisory hook skipped';
|
|
182
|
+
return `UKit WARNING: hook ${scriptName} could not be launched (${cause}) and was SKIPPED — ${gateText}. ${SPAWN_UNAVAILABLE_FIX}\n`;
|
|
183
|
+
}
|
|
184
|
+
|
|
172
185
|
async function runModuleStep({ scriptPath, payload, payloadText, projectRoot, env, deadlineMs }) {
|
|
173
186
|
const controller = new AbortController();
|
|
174
187
|
let timer;
|
|
@@ -308,6 +321,13 @@ async function run(payloadText, scriptArgs, { chainMarker = true, decisionShortC
|
|
|
308
321
|
// TASK-001: `.mjs` args run in-proc via runHook(ctx); `.sh` stays a child
|
|
309
322
|
// process. Both paths share deadline/budget, results[] shape, and break
|
|
310
323
|
// conditions.
|
|
324
|
+
// FR-003/FR-004: non-.mjs steps launch through the resolver (absolute Git
|
|
325
|
+
// Bash on win32 for `.sh`, the script itself everywhere else). `viaBash`
|
|
326
|
+
// is the ONLY thing that can turn a launch failure into spawn-unavailable.
|
|
327
|
+
const launch = scriptPath.endsWith('.mjs')
|
|
328
|
+
? { command: scriptPath, args: [], viaBash: false }
|
|
329
|
+
: resolveHookCommand(scriptPath);
|
|
330
|
+
const { viaBash } = launch;
|
|
311
331
|
const result = scriptPath.endsWith('.mjs')
|
|
312
332
|
? await runModuleStep({
|
|
313
333
|
scriptPath,
|
|
@@ -317,10 +337,13 @@ async function run(payloadText, scriptArgs, { chainMarker = true, decisionShortC
|
|
|
317
337
|
env: stepEnv,
|
|
318
338
|
deadlineMs: stepDeadlineMs,
|
|
319
339
|
})
|
|
320
|
-
:
|
|
321
|
-
|
|
340
|
+
: launch.command === null
|
|
341
|
+
// No bash found on win32: nothing is spawned (never a bare `bash`).
|
|
342
|
+
? { code: 1, stdout: '', stderr: '', failureKind: 'error', spawnFailed: true, errorCode: null }
|
|
343
|
+
: await runHookProcess({
|
|
344
|
+
command: launch.command,
|
|
322
345
|
deadlineMs: stepDeadlineMs,
|
|
323
|
-
args:
|
|
346
|
+
args: launch.args,
|
|
324
347
|
input: payloadText,
|
|
325
348
|
maxBuffer: MAX_BUFFER_BYTES,
|
|
326
349
|
cwd: projectRoot,
|
|
@@ -331,6 +354,38 @@ async function run(payloadText, scriptArgs, { chainMarker = true, decisionShortC
|
|
|
331
354
|
// decision JSON.
|
|
332
355
|
env: stepEnv,
|
|
333
356
|
});
|
|
357
|
+
const launchFailed = viaBash && (
|
|
358
|
+
launch.command === null
|
|
359
|
+
|| (result.spawnFailed === true && SPAWN_UNAVAILABLE_CODES.has(result.errorCode))
|
|
360
|
+
);
|
|
361
|
+
if (launchFailed && scriptName !== 'block-dangerous.sh' && fs.existsSync(scriptPath)) {
|
|
362
|
+
const stepElapsedMs = Date.now() - childStartedAt;
|
|
363
|
+
results.push({
|
|
364
|
+
scriptName,
|
|
365
|
+
code: 0,
|
|
366
|
+
stdout: '',
|
|
367
|
+
stderr: spawnUnavailableWarning(scriptName, launch.command === null ? 'bash.exe not found' : result.errorCode),
|
|
368
|
+
failureKind: 'spawn-unavailable',
|
|
369
|
+
killed: false,
|
|
370
|
+
elapsedMs: stepElapsedMs,
|
|
371
|
+
});
|
|
372
|
+
recordHookTiming({
|
|
373
|
+
projectRoot,
|
|
374
|
+
sessionId: payload?.session_id,
|
|
375
|
+
event: payload?.hook_event_name || null,
|
|
376
|
+
tool: payload?.tool_name || null,
|
|
377
|
+
toolUseId: payload?.tool_use_id || null,
|
|
378
|
+
hook: scriptName,
|
|
379
|
+
elapsedMs: stepElapsedMs,
|
|
380
|
+
outcome: 'spawn-unavailable',
|
|
381
|
+
});
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
if (launch.command === null) {
|
|
385
|
+
// Resolver found no bash but the step does not qualify for the degrade
|
|
386
|
+
// (script missing / block-dangerous.sh): fail closed exactly like a spawn error.
|
|
387
|
+
result.stderr = `UKit hook ${scriptName} could not be launched: bash.exe not found\n`;
|
|
388
|
+
}
|
|
334
389
|
const failureKind = chainFailureKind(result);
|
|
335
390
|
// FR-001: on infra failure (error/timeout — no module verdict) the result
|
|
336
391
|
// code is 2 when the step is fail-closed, else 1.
|
|
@@ -609,18 +664,34 @@ try {
|
|
|
609
664
|
.filter((r) => r !== decisionResult && r !== last && typeof r.stdout === 'string' && r.stdout.length > 0)
|
|
610
665
|
.map((r) => r.stdout)
|
|
611
666
|
.join('');
|
|
667
|
+
// FR-005: spawn-unavailable steps never block, but must stay visible. Their
|
|
668
|
+
// warnings go to stderr exactly ONCE (here) and, where stdout is a clean
|
|
669
|
+
// JSON document the runner owns, into `systemMessage`.
|
|
670
|
+
const warnings = chain.results
|
|
671
|
+
.filter((r) => r.failureKind === 'spawn-unavailable')
|
|
672
|
+
.map((r) => r.stderr)
|
|
673
|
+
.join('');
|
|
674
|
+
if (warnings) process.stderr.write(warnings);
|
|
612
675
|
if (decisionOwnsVerdict) {
|
|
613
676
|
let verdictOut = decisionResult.stdout;
|
|
614
677
|
try {
|
|
615
678
|
const doc = JSON.parse(verdictOut);
|
|
616
679
|
const hso = doc?.hookSpecificOutput;
|
|
680
|
+
let changed = false;
|
|
617
681
|
if (hso?.permissionDecision === 'deny') {
|
|
618
682
|
const advice = breakerAdvice(hso.permissionDecisionReason);
|
|
619
683
|
if (advice) {
|
|
620
684
|
hso.permissionDecisionReason = `${hso.permissionDecisionReason}\n${advice}`;
|
|
621
|
-
|
|
685
|
+
changed = true;
|
|
622
686
|
}
|
|
623
687
|
}
|
|
688
|
+
if (warnings && doc && typeof doc === 'object' && !Array.isArray(doc)) {
|
|
689
|
+
doc.systemMessage = typeof doc.systemMessage === 'string' && doc.systemMessage
|
|
690
|
+
? `${doc.systemMessage}\n${warnings.trimEnd()}`
|
|
691
|
+
: warnings.trimEnd();
|
|
692
|
+
changed = true;
|
|
693
|
+
}
|
|
694
|
+
if (changed) verdictOut = JSON.stringify(doc);
|
|
624
695
|
} catch { /* unparseable verdict: replay it untouched */ }
|
|
625
696
|
process.stdout.write(verdictOut);
|
|
626
697
|
if (decisionResult.stderr) process.stderr.write(decisionResult.stderr);
|
|
@@ -669,8 +740,14 @@ try {
|
|
|
669
740
|
} else {
|
|
670
741
|
if (contextStdout) process.stdout.write(contextStdout);
|
|
671
742
|
if (last.stdout) process.stdout.write(last.stdout);
|
|
672
|
-
|
|
743
|
+
// A spawn-unavailable `last` carries only the warning already written above.
|
|
744
|
+
if (last.stderr && last.failureKind !== 'spawn-unavailable') process.stderr.write(last.stderr);
|
|
673
745
|
process.exitCode = last.code === 0 ? 0 : (lastIsFailClosed ? 2 : 0);
|
|
746
|
+
// FR-005(c): exiting 0 with an otherwise empty stdout — surface the warning
|
|
747
|
+
// to the host as a systemMessage. Plain context stdout is left untouched.
|
|
748
|
+
if (warnings && process.exitCode === 0 && !contextStdout && !last.stdout) {
|
|
749
|
+
process.stdout.write(JSON.stringify({ systemMessage: warnings.trimEnd() }));
|
|
750
|
+
}
|
|
674
751
|
}
|
|
675
752
|
}
|
|
676
753
|
}
|
|
@@ -18,15 +18,29 @@
|
|
|
18
18
|
// assumes group semantics.
|
|
19
19
|
//
|
|
20
20
|
// Contract (TASK-016 Interfaces):
|
|
21
|
-
// runHookProcess({command, args, input, deadlineMs, maxBuffer, ...}) ->
|
|
22
|
-
// Promise<{code, signal, stdout, stderr, failureKind, elapsedMs}>
|
|
21
|
+
// runHookProcess({command, args, input, deadlineMs, maxBuffer, spawnImpl, ...}) ->
|
|
22
|
+
// Promise<{code, signal, stdout, stderr, failureKind, elapsedMs, spawnFailed, errorCode}>
|
|
23
23
|
// failureKind: 'none' | 'deadline' | 'signal' | 'overflow' | 'error'
|
|
24
24
|
// 'deadline' — this runner killed the child because the deadline expired
|
|
25
25
|
// 'signal' — the child died from a signal this runner did not send
|
|
26
26
|
// 'overflow' — captured output exceeded maxBuffer; the tree was culled
|
|
27
27
|
// 'error' — the child never spawned (ENOENT and friends)
|
|
28
|
+
// spawnFailed: true only when the process was NEVER started — derived from child
|
|
29
|
+
// event order (an 'error' with no preceding 'spawn' event, or spawn() throwing
|
|
30
|
+
// synchronously), never from timing. An 'error' after 'spawn' is not a spawn failure.
|
|
31
|
+
// errorCode: the error's `code` (ENOENT, EINVAL, EPIPE, ...) or null.
|
|
32
|
+
// spawnImpl: injectable spawn function (default child_process.spawn) for tests.
|
|
33
|
+
//
|
|
34
|
+
// Windows .sh launching (resolveHookCommand / resolveWindowsBash):
|
|
35
|
+
// Windows cannot exec a `.sh` file directly, so on win32 a `.sh` hook is launched
|
|
36
|
+
// through an ABSOLUTE Git Bash path (never a bare `bash`, which Windows would look up
|
|
37
|
+
// in the child cwd first). Script paths are passed with forward slashes. POSIX spawns
|
|
38
|
+
// the script directly, unchanged. UKIT_HOOK_SHELL_PLATFORM overrides the platform seen
|
|
39
|
+
// by resolveHookCommand only (tests/diagnostics); UKIT_BASH_BIN overrides the bash path.
|
|
28
40
|
|
|
29
41
|
import { spawn } from 'node:child_process';
|
|
42
|
+
import fs from 'node:fs';
|
|
43
|
+
import path from 'node:path';
|
|
30
44
|
|
|
31
45
|
// POSIX only. Windows kill() targets the direct child; process groups do not
|
|
32
46
|
// exist there, so pretending otherwise would be a silent, wrong assumption.
|
|
@@ -41,6 +55,93 @@ export const KILL_GRACE_MS = 500;
|
|
|
41
55
|
// runner past its own budget.
|
|
42
56
|
const HARD_SETTLE_SLACK_MS = 2000;
|
|
43
57
|
|
|
58
|
+
// Error codes meaning "the launcher itself could not be started".
|
|
59
|
+
export const SPAWN_UNAVAILABLE_CODES = new Set([
|
|
60
|
+
'ENOENT',
|
|
61
|
+
'EINVAL',
|
|
62
|
+
'EACCES',
|
|
63
|
+
'EPERM',
|
|
64
|
+
'ENOEXEC',
|
|
65
|
+
'EFTYPE',
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
/** Windows path -> forward slashes, so bash's dirname/$0 logic works. */
|
|
69
|
+
export function toBashPath(p) {
|
|
70
|
+
return String(p).replace(/\\/g, '/');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const win32 = path.win32;
|
|
74
|
+
|
|
75
|
+
function safeExists(exists, candidate) {
|
|
76
|
+
try {
|
|
77
|
+
return Boolean(exists(candidate));
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Locate Git Bash on Windows. Returns an absolute path or null — never a bare
|
|
85
|
+
* or relative name (Windows resolves bare names in the child cwd first, which
|
|
86
|
+
* would let a planted bash.exe in a cloned repo run).
|
|
87
|
+
*/
|
|
88
|
+
export function resolveWindowsBash({ env = process.env, exists = fs.existsSync } = {}) {
|
|
89
|
+
const override = env.UKIT_BASH_BIN;
|
|
90
|
+
if (typeof override === 'string' && override && win32.isAbsolute(override)) return override;
|
|
91
|
+
|
|
92
|
+
const probe = (candidate) =>
|
|
93
|
+
typeof candidate === 'string' &&
|
|
94
|
+
candidate !== '' &&
|
|
95
|
+
win32.isAbsolute(candidate) &&
|
|
96
|
+
safeExists(exists, candidate);
|
|
97
|
+
|
|
98
|
+
const claude = env.CLAUDE_CODE_GIT_BASH_PATH;
|
|
99
|
+
if (probe(claude)) return claude;
|
|
100
|
+
|
|
101
|
+
const defaults = [
|
|
102
|
+
'C:\\Program Files\\Git\\bin\\bash.exe',
|
|
103
|
+
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
|
|
104
|
+
];
|
|
105
|
+
if (typeof env.LOCALAPPDATA === 'string' && env.LOCALAPPDATA) {
|
|
106
|
+
defaults.push(win32.join(env.LOCALAPPDATA, 'Programs', 'Git', 'bin', 'bash.exe'));
|
|
107
|
+
}
|
|
108
|
+
for (const candidate of defaults) {
|
|
109
|
+
if (probe(candidate)) return candidate;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const trimSlash = (p) => p.replace(/\\+$/, '').toLowerCase();
|
|
113
|
+
const system32 = trimSlash(win32.join(env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows', 'System32'));
|
|
114
|
+
const rawPath = env.PATH ?? env.Path ?? '';
|
|
115
|
+
for (const rawEntry of String(rawPath).split(';')) {
|
|
116
|
+
const entry = rawEntry.trim().replace(/^"+|"+$/g, '').trim();
|
|
117
|
+
if (!entry || !win32.isAbsolute(entry)) continue;
|
|
118
|
+
const norm = trimSlash(win32.normalize(entry));
|
|
119
|
+
if (norm === system32 || norm.endsWith('\\microsoft\\windowsapps')) continue;
|
|
120
|
+
const candidate = win32.join(entry, 'bash.exe');
|
|
121
|
+
if (safeExists(exists, candidate)) return candidate;
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Decide how to launch a hook script. win32 + `.sh` -> Git Bash (command may be
|
|
128
|
+
* null when none was found); everything else spawns the script directly.
|
|
129
|
+
*/
|
|
130
|
+
export function resolveHookCommand(
|
|
131
|
+
scriptPath,
|
|
132
|
+
{ env = process.env, platform = env.UKIT_HOOK_SHELL_PLATFORM || process.platform, exists = fs.existsSync } = {},
|
|
133
|
+
) {
|
|
134
|
+
if (typeof scriptPath !== 'string') return { command: '', args: [], viaBash: false };
|
|
135
|
+
if (platform === 'win32' && /\.sh$/i.test(scriptPath)) {
|
|
136
|
+
return {
|
|
137
|
+
command: resolveWindowsBash({ env, exists }),
|
|
138
|
+
args: [toBashPath(scriptPath)],
|
|
139
|
+
viaBash: true,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
return { command: scriptPath, args: [], viaBash: false };
|
|
143
|
+
}
|
|
144
|
+
|
|
44
145
|
/**
|
|
45
146
|
* Signal a process GROUP by pid (negative-pid kill). Swallows ESRCH — the
|
|
46
147
|
* child may have exited between arming the timer and this call, or the pid may
|
|
@@ -73,18 +174,20 @@ export function runHookProcess({
|
|
|
73
174
|
cwd,
|
|
74
175
|
env = process.env,
|
|
75
176
|
graceMs = KILL_GRACE_MS,
|
|
177
|
+
spawnImpl = spawn,
|
|
76
178
|
}) {
|
|
77
179
|
return new Promise((resolve) => {
|
|
78
180
|
const startedAt = Date.now();
|
|
79
181
|
let child;
|
|
80
182
|
try {
|
|
81
|
-
child =
|
|
183
|
+
child = spawnImpl(command, args, {
|
|
82
184
|
cwd,
|
|
83
185
|
env,
|
|
84
186
|
// Detached ONLY on POSIX: the child becomes a group leader so the whole
|
|
85
187
|
// tree dies together. Windows never gets a group assumption.
|
|
86
188
|
detached: GROUP_KILL_SUPPORTED,
|
|
87
189
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
190
|
+
windowsHide: true,
|
|
88
191
|
});
|
|
89
192
|
} catch (err) {
|
|
90
193
|
resolve({
|
|
@@ -94,6 +197,8 @@ export function runHookProcess({
|
|
|
94
197
|
stderr: String(err?.message || err),
|
|
95
198
|
failureKind: 'error',
|
|
96
199
|
elapsedMs: Date.now() - startedAt,
|
|
200
|
+
spawnFailed: true,
|
|
201
|
+
errorCode: err?.code ?? null,
|
|
97
202
|
});
|
|
98
203
|
return;
|
|
99
204
|
}
|
|
@@ -104,6 +209,8 @@ export function runHookProcess({
|
|
|
104
209
|
let hardSettled = false;
|
|
105
210
|
let overflowed = false;
|
|
106
211
|
let spawnError = null;
|
|
212
|
+
let spawned = false;
|
|
213
|
+
let spawnFailed = false;
|
|
107
214
|
let stdoutBuf = Buffer.alloc(0);
|
|
108
215
|
let stderrBuf = Buffer.alloc(0);
|
|
109
216
|
const stdoutRef = { value: stdoutBuf };
|
|
@@ -146,6 +253,8 @@ export function runHookProcess({
|
|
|
146
253
|
stderr: stderrRef.value.toString('utf8'),
|
|
147
254
|
failureKind,
|
|
148
255
|
elapsedMs: Date.now() - startedAt,
|
|
256
|
+
spawnFailed,
|
|
257
|
+
errorCode: spawnError?.code ?? null,
|
|
149
258
|
});
|
|
150
259
|
};
|
|
151
260
|
|
|
@@ -238,9 +347,16 @@ export function runHookProcess({
|
|
|
238
347
|
}, deadlineMs + Math.max(0, graceMs) + HARD_SETTLE_SLACK_MS);
|
|
239
348
|
}
|
|
240
349
|
|
|
350
|
+
// Event order, not timing, tells "never started" from "failed after start".
|
|
351
|
+
child.once('spawn', () => {
|
|
352
|
+
spawned = true;
|
|
353
|
+
});
|
|
241
354
|
child.on('error', (err) => {
|
|
242
355
|
// Spawn failures (ENOENT and friends) emit 'error' without 'close'.
|
|
243
|
-
if (!spawnError)
|
|
356
|
+
if (!spawnError) {
|
|
357
|
+
spawnError = err;
|
|
358
|
+
spawnFailed = !spawned;
|
|
359
|
+
}
|
|
244
360
|
finish();
|
|
245
361
|
});
|
|
246
362
|
child.on('close', () => {
|
|
@@ -273,6 +273,18 @@ function parseStructuredDecision(stdout) {
|
|
|
273
273
|
|
|
274
274
|
function translateExecResult(scriptName, execResult) {
|
|
275
275
|
const failureKind = typeof execResult?.failureKind === 'string' ? execResult.failureKind : null;
|
|
276
|
+
// The runner could not launch a .sh hook at all (win32 without bash): a loud warning,
|
|
277
|
+
// never a block, and no captured content travels with it.
|
|
278
|
+
if (failureKind === 'spawn-unavailable') {
|
|
279
|
+
const launchStderr = typeof execResult?.stderr === 'string' ? execResult.stderr : '';
|
|
280
|
+
return {
|
|
281
|
+
block: false,
|
|
282
|
+
warning: launchStderr.trim()
|
|
283
|
+
|| `${scriptName} could not be launched and was skipped (spawn-unavailable) — install Git for Windows or set UKIT_BASH_BIN.`,
|
|
284
|
+
stdout: '',
|
|
285
|
+
stderr: launchStderr,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
276
288
|
// Infrastructure outcomes produce no verdict: no captured content travels with
|
|
277
289
|
// them (overflowed stdout is truncated mid-stream and may embed secrets).
|
|
278
290
|
const infrastructure = failureKind
|
|
@@ -280,13 +280,25 @@ Internals: `src/core/permissionPolicy.js` compiles the semantic policy (preceden
|
|
|
280
280
|
non-zero on its own), `output-overflow` (capture exceeded the 2 MiB maxBuffer and the
|
|
281
281
|
tree was culled), `timeout` (the runner killed the child at its deadline), `signal`
|
|
282
282
|
(the child died from a signal the runner did not send), `error` (the child never
|
|
283
|
-
spawned), `
|
|
284
|
-
|
|
283
|
+
spawned), `spawn-unavailable` (win32 only: no usable bash could be found or started for
|
|
284
|
+
a `.sh` script — see the launcher bullet below), `budget-exhausted` (the script never
|
|
285
|
+
ran — the chain total budget was spent first). The `--emit-verdict` contract maps them onto one exit code: `ok`/`exit-code 0`
|
|
285
286
|
and any non-fail-closed failure → exit 0 (fail OPEN); a real `exit-code 2` block, or
|
|
286
287
|
ANY failure on a fail-closed gate (`output-overflow`, `timeout`, `signal`, `error`,
|
|
287
288
|
`budget-exhausted`) → exit 2 (fail CLOSED). An overflowed capture is dropped whole
|
|
288
289
|
before it can be replayed, so a truncated stream can never masquerade as a verdict.
|
|
289
290
|
`tests/hooks/hookChainFailureSurface.test.js` pins these surfaces.
|
|
291
|
+
- **Windows bash launcher.** On win32 a `.sh` hook is not executed directly; the runner
|
|
292
|
+
launches it through Git Bash. The bash binary is the first hit of: an absolute
|
|
293
|
+
`UKIT_BASH_BIN` → `CLAUDE_CODE_GIT_BASH_PATH` → the Git for Windows default install
|
|
294
|
+
paths → absolute `PATH` entries (the System32 and WindowsApps `bash.exe`, i.e. WSL, are
|
|
295
|
+
skipped; a relative or cwd `bash.exe` is never used). The script path is passed with
|
|
296
|
+
forward slashes. When no candidate exists or the spawn fails, the entry is
|
|
297
|
+
`spawn-unavailable`: it warns instead of denying every Edit/Write/Bash. That kind is
|
|
298
|
+
win32-launcher-only — POSIX spawn errors stay `error` and fail closed, and timeouts,
|
|
299
|
+
real `exit-code 2` blocks, missing gate files and `block-dangerous.sh` stay fail closed
|
|
300
|
+
on every platform. `UKIT_HOOK_SHELL_PLATFORM` is a diagnostic override of the platform
|
|
301
|
+
the launcher assumes.
|
|
290
302
|
- **stderr is a diagnostic channel, not verdict payload.** Under `--emit-verdict` the
|
|
291
303
|
runner drops the stderr of every non-verdict script: stdout is the verdict and Claude
|
|
292
304
|
Code parses it as ONE JSON document, so interleaving arbitrary hook diagnostics there
|