@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.4.21",
3
+ "version": "3.4.22",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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 = `${counts.rowsScanned} chain row(s) scanned — fail-closed outcomes: ${breakdown}`;
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(
@@ -0,0 +1,2 @@
1
+ # Hook scripts must stay LF: a CRLF checkout makes bash fail on "\r".
2
+ *.sh text eol=lf
@@ -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
- : await runHookProcess({
321
- command: scriptPath,
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
- verdictOut = JSON.stringify(doc);
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
- if (last.stderr) process.stderr.write(last.stderr);
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 = spawn(command, args, {
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) spawnError = err;
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), `budget-exhausted` (the script never ran — the chain total budget was spent
284
- first). The `--emit-verdict` contract maps them onto one exit code: `ok`/`exit-code 0`
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