@ngockhoale/ukit 2.7.4 → 2.7.5

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,44 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 2.7.5 - 2026-09-20
6
+
7
+ Hook performance work — cycle C40 (TASK-001..007): `.mjs` module steps,
8
+ fail-closed registry, and a hook budget guard.
9
+
10
+ - `hook-chain-runner` supports `"<path>.mjs[:N]"` module steps: the arg is
11
+ imported in-proc and its `runHook(ctx)` is invoked with
12
+ `{payload, payloadText, projectRoot, env, deadlineMs, signal}` — no child
13
+ process spawn on the hot path. A throw reports `error`, a
14
+ `deadlineMs`/`AbortSignal` breach reports `timeout` + `killed`; infra
15
+ failures exit 2 when the step is fail-closed, else 1. Every step appends a
16
+ `recordHookTiming` telemetry row under its `<name>.mjs` hook name, and
17
+ `UKIT_HOOK_DEADLINE_MS` is scrubbed from `process.env`/`ctx.env` around the
18
+ import so a stale host deadline cannot leak into module steps.
19
+ - Fail-closed registry extended: `sensitive-data-guard.mjs` and
20
+ `block-dangerous.mjs` join `FAIL_CLOSED_SCRIPTS`, so module-step verdicts
21
+ keep the same fail-closed posture as the `.sh` wrappers they replaced.
22
+ - `sensitive-data-guard`, `block-dangerous`, and `record-execution` ported to
23
+ dual-mode modules: each `.mjs` exports `runHook(ctx)` for chain execution
24
+ plus an `isDirectRun()` CLI path for standalone/debug invocation;
25
+ `hookFailClosed` is exported per module (`true` for the two gates, `false`
26
+ for the telemetry sink). The `.sh` files remain as thin wrappers — staging,
27
+ salvage glue (`UKIT_SALVAGED_*` env), telemetry, then a single
28
+ `node <module>.mjs` call — with byte-identical verdicts verified against the
29
+ pre-port implementations. `record-execution.mjs` also exposes
30
+ `setLedgerMessageEmitter` on `execution-ledger.mjs` so in-proc runs route
31
+ the `noteSweepFailure` systemMessage through the emitter instead of fd 1.
32
+ - Settings + bridge wiring: `settings.json` chains and the omp bridge
33
+ `HOOK_EVENT_MAP` now point at the `.mjs` steps; the bridge's
34
+ `TIMEOUT_STAYS_CLOSED` / payload-stays-closed checks cover
35
+ `block-dangerous.mjs` so verdicts stay identical after the swap.
36
+ - Hook budget guard: `tests/hooks/hookChainBudget.test.js` enforces ≤8 chain
37
+ steps per group, requires a `:N` suffix on every `.sh|mjs` arg, and pins
38
+ outer `timeout` ≥ Σ(:N) + 10s — adding or removing a hook without updating
39
+ the outer fails loudly. The same suite asserts the settings↔bridge mirror
40
+ 1:1 for every event/matcher lane. The contract is documented under
41
+ `UKIT_INTERNALS.md → Hook budget`.
42
+
5
43
  ## 2.7.4 - 2026-09-20
6
44
 
7
45
  Deferred hook findings + residual risks — cycle C39 (TASK-001..005).
@@ -1096,6 +1096,20 @@ items:
1096
1096
  packs:
1097
1097
  - core
1098
1098
 
1099
+ # TASK-004 (C40): advisory module the thin .sh wrapper delegates to and the
1100
+ # chain runner loads in-proc.
1101
+ - id: hook-record-execution-module
1102
+ type: hook
1103
+ sourceTemplate: .claude/hooks/record-execution.mjs
1104
+ targetPath: .claude/hooks/record-execution.mjs
1105
+ requires:
1106
+ - ukit-runtime-scripts
1107
+ mergeStrategy: overwrite_with_backup
1108
+ variables: []
1109
+ enabledByDefault: true
1110
+ packs:
1111
+ - core
1112
+
1099
1113
  - id: hook-completion-gate
1100
1114
  type: hook
1101
1115
  sourceTemplate: .claude/hooks/completion-gate.sh
@@ -1141,6 +1155,20 @@ items:
1141
1155
  packs:
1142
1156
  - core
1143
1157
 
1158
+ # TASK-003 (C40): fail-closed module the thin .sh wrapper delegates to and
1159
+ # the chain runner loads in-proc.
1160
+ - id: hook-block-dangerous-module
1161
+ type: hook
1162
+ sourceTemplate: .claude/hooks/block-dangerous.mjs
1163
+ targetPath: .claude/hooks/block-dangerous.mjs
1164
+ requires:
1165
+ - ukit-runtime-scripts
1166
+ mergeStrategy: overwrite_with_backup
1167
+ variables: []
1168
+ enabledByDefault: true
1169
+ packs:
1170
+ - core
1171
+
1144
1172
  # requires: [ukit-runtime-scripts] is forward-declared for the shared sensitive-value
1145
1173
  # scanner (consumed by the guard once TASK-039 wires it). The guard is fail-closed, so
1146
1174
  # its runtime dependency must be installed first — keeps the ordering posture explicit.
@@ -1156,6 +1184,21 @@ items:
1156
1184
  packs:
1157
1185
  - core
1158
1186
 
1187
+ # TASK-002 (C40): dual-mode module the thin .sh wrapper delegates to and the
1188
+ # chain runner loads in-proc. Requires ukit-runtime-scripts for the scanner
1189
+ # AND hook-field-salvage.mjs (both ship via that directory item).
1190
+ - id: hook-sensitive-data-guard-module
1191
+ type: hook
1192
+ sourceTemplate: .claude/hooks/sensitive-data-guard.mjs
1193
+ targetPath: .claude/hooks/sensitive-data-guard.mjs
1194
+ requires:
1195
+ - ukit-runtime-scripts
1196
+ mergeStrategy: overwrite_with_backup
1197
+ variables: []
1198
+ enabledByDefault: true
1199
+ packs:
1200
+ - core
1201
+
1159
1202
  - id: hook-handoff-model-guard
1160
1203
  type: hook
1161
1204
  sourceTemplate: .claude/hooks/handoff-model-guard.sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "2.7.4",
3
+ "version": "2.7.5",
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",
@@ -0,0 +1,377 @@
1
+ // block-dangerous.mjs — C40 TASK-003 port of block-dangerous.sh into a
2
+ // dual-mode module (SPEC §FR-006, §8):
3
+ //
4
+ // * `runHook(ctx)` — in-proc chain step. ctx = { payload, payloadText,
5
+ // projectRoot, env, deadlineMs, signal } -> { code, stdout, stderr }.
6
+ // Byte-identical verdicts to the .sh: dangerous pattern / unsafe rm verdict
7
+ // -> identical permissionDecision JSON + `BLOCKED: <reason>` stderr; safe
8
+ // command -> code 0 empty. Under the chain marker (UKIT_HOOK_CHAIN_RUNNER)
9
+ // decisions are `ask` + code 2; on the direct host they are `deny` + code 0
10
+ // (bypassPermissions auto-approves `ask` — TASK-223/probe verdict).
11
+ // * `isDirectRun()` CLI mode — the .sh thin wrapper execs
12
+ // `node block-dangerous.mjs` with the exact env contract the bash glue
13
+ // produced (INPUT_FILE PROJECT_ROOT UKIT_HOOK_DEADLINE_MS
14
+ // UKIT_SALVAGE_ACTIVE UKIT_SALVAGED_COMMAND).
15
+ //
16
+ // FAIL-CLOSED gate. The raw command and matched pattern are NEVER echoed —
17
+ // arguments and comments can carry secrets, and the pattern text itself
18
+ // restates the dangerous command. Truncated/degraded input salvages only a
19
+ // provably-complete tool_input.command via ukit/runtime/hook-field-salvage.mjs;
20
+ // anything else refuses `deny` — a partial command is never evaluated.
21
+
22
+ import fs from 'node:fs';
23
+ import fsp from 'node:fs/promises';
24
+ import path from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+ import { salvageField } from '../ukit/runtime/hook-field-salvage.mjs';
27
+
28
+ export const hookFailClosed = true;
29
+
30
+ // POSIX [[:space:]] exactly (grep -E semantics; JS \s also matches unicode).
31
+ const SP = '[\\t\\n\\v\\f\\r ]';
32
+
33
+ // --- byte-pinned verdicts ----------------------------------------------------
34
+ const REASON_DANGEROUS =
35
+ 'Dangerous command pattern detected; it could cause irreversible damage. UKit defers this to a human decision.';
36
+ const REASON_UNSAFE_TARGET =
37
+ 'Unsafe delete target detected; recursive force-delete could cause irreversible damage. UKit defers this to a human decision.';
38
+ const REASON_GENERIC_RM =
39
+ 'Recursive force-delete outside safe cleanup targets (dist/build/coverage/.next/.nuxt/.turbo/tmp/.cache/node_modules) is destructive. UKit defers this to a human decision.';
40
+ const REASON_SALVAGE_REFUSAL =
41
+ 'UKit could not fully read this tool-call payload (stdin staging was cut at the deadline/size bound) and the command could not be recovered, so dangerous-command gate cannot prove it safe.';
42
+
43
+ const TIMEOUT_REFUSAL = {
44
+ code: 2,
45
+ stdout:
46
+ '{"systemMessage":"UKit dangerous-command gate timed out before finishing its scan; blocked fail-closed rather than pass an uninspected payload."}\n',
47
+ stderr:
48
+ 'BLOCKED: dangerous-command gate timed out; blocked fail-closed rather than pass an uninspected payload.\n',
49
+ };
50
+ const CRASH_REFUSAL = {
51
+ code: 2,
52
+ stdout:
53
+ '{"systemMessage":"UKit dangerous-command gate crashed before finishing its scan; blocked fail-closed rather than pass an uninspected payload. Run `ukit install` if this repeats."}\n',
54
+ stderr: 'BLOCKED: dangerous-command gate crashed; payload never inspected (fail-closed).\n',
55
+ };
56
+
57
+ // ukit_emit_permission_decision parity (hook-input.sh): the JSON shape is
58
+ // harness-agnostic, the exit code is not — direct host exits 0 (non-zero
59
+ // discards stdout), chain exits 2 (bridge parses stdout only on code 2).
60
+ // stderr always carries the BLOCKED line.
61
+ function emitDecision(decision, reason, env) {
62
+ const inChain = Boolean(env.UKIT_HOOK_CHAIN_RUNNER);
63
+ return {
64
+ code: inChain ? 2 : 0,
65
+ stdout:
66
+ `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"${decision}",` +
67
+ `"permissionDecisionReason":"${reason}"}}\n`,
68
+ stderr: `BLOCKED: ${reason}\n`,
69
+ };
70
+ }
71
+ const emitDangerous = (reason, env) =>
72
+ emitDecision(env.UKIT_HOOK_CHAIN_RUNNER ? 'ask' : 'deny', reason, env);
73
+ const emitRefusal = (env) => emitDecision('deny', REASON_SALVAGE_REFUSAL, env);
74
+
75
+ // --- command extraction ------------------------------------------------------
76
+ // jq/node heredoc parity: tool_input.command string, else ''.
77
+ function extractCommand({ rawInput, env }) {
78
+ if (env.UKIT_SALVAGE_ACTIVE === '1') {
79
+ // TASK-003 degraded path: the wrapper already recovered a COMPLETE command —
80
+ // skip normal extraction (the staged prefix would not parse anyway).
81
+ return { command: env.UKIT_SALVAGED_COMMAND ?? '' };
82
+ }
83
+ try {
84
+ const payload = JSON.parse(rawInput || '{}');
85
+ const command = payload?.tool_input?.command;
86
+ return { command: typeof command === 'string' ? command : '' };
87
+ } catch {
88
+ return { unparseable: true };
89
+ }
90
+ }
91
+
92
+ // --- verbatim ports of the .sh scan semantics ---------------------------------
93
+ // CMD_HEAD=$(echo "$COMMAND" | sed -E 's/^[[:space:]]*//' | awk '{print $1}' | basename)
94
+ const SAFE_TEXT_HEADS = new Set(['grep', 'egrep', 'fgrep', 'rg', 'ag', 'awk', 'sed', 'echo', 'printf', 'cat']);
95
+ const PIPE_TO_SHELL_RE = new RegExp(`\\|${SP}*(bash|sh|zsh|eval|xargs)(${SP}|$)`, 'm');
96
+
97
+ // DANGEROUS_PATTERNS — grep -E semantics preserved. Verified against BSD/GNU
98
+ // grep: the fork-bomb entry's `{` is an invalid interval, so it (and every
99
+ // other entry) is a literal substring except the three that carry real ERE.
100
+ const DANGEROUS_LITERALS = [
101
+ 'rm -rf /',
102
+ 'rm -rf ~',
103
+ 'git push --force',
104
+ 'git push -f ',
105
+ 'git reset --hard',
106
+ 'git clean -fd',
107
+ '> /dev/sda',
108
+ 'mkfs.',
109
+ ':(){ :|:& };:',
110
+ 'dd if=/dev/',
111
+ ];
112
+ const DANGEROUS_REGEXES = [
113
+ // "rm -rf \.?\.([[:space:]]|$)" — rm -rf . / rm -rf .. (+ trailing ws/EOL)
114
+ new RegExp(`rm -rf \\.?\\.(${SP}|$)`, 'm'),
115
+ /git checkout \./,
116
+ /git restore \./,
117
+ ];
118
+
119
+ // rm segment analysis — byte-identical port of the .sh per-segment allowlist.
120
+ const SAFE_NAMES = '(?:dist|build|coverage|\\.next|\\.nuxt|\\.turbo|tmp|temp|\\.cache|node_modules)';
121
+ const SAFE_ONE_TARGET_RE = new RegExp(`^(?:\\./)?${SAFE_NAMES}(?:/.*)?$`);
122
+ const UNSAFE_ONE_TARGET_RE = /^(\/.*|~|~\/.*|\.\.|\.\.\/.*|\.)$/;
123
+ const RM_WORD_RE = new RegExp(`(^|[;&|${SP.slice(1, -1)}"'])rm([${SP.slice(1, -1)}"']|$)`, 'm');
124
+ const UNSAFE_TARGET_RE = new RegExp(`(^|${SP})(\\/|~|\\.\\.?($|\\/)|\\.($|\\/))`, 'm');
125
+ const SAFE_DELETE_RE = new RegExp(`(^|${SP})(\\./)?${SAFE_NAMES}(\\/|${SP}|$)`, 'm');
126
+ const HAS_RECURSIVE_RE = new RegExp(`(^|${SP})--recursive|(^|${SP})-[a-zA-Z]*[rR]`, 'm');
127
+ const HAS_FORCE_RE = new RegExp(`(^|${SP})--force|(^|${SP})-[a-zA-Z]*f`, 'm');
128
+
129
+ const basename = (token) => token.split('/').pop();
130
+
131
+ // ukit_canonical_path parity: realpath -m semantics — resolve the deepest
132
+ // existing ancestor, append the lexical remainder; unresolvable -> '' (the
133
+ // caller fails closed, never silently allows). UKIT_REALPATH_MISSING=1 (set by
134
+ // the wrapper when no system realpath exists) forces the same fail-closed —
135
+ // the pre-port gate could not prove containment without realpath.
136
+ function canonicalPath(p, env) {
137
+ if (env && env.UKIT_REALPATH_MISSING === '1') return '';
138
+ try {
139
+ const out = fs.realpathSync.native(p);
140
+ if (out) return out;
141
+ } catch {}
142
+ let rest = '';
143
+ let cur = p;
144
+ const existsOrLink = (q) => {
145
+ try {
146
+ fs.lstatSync(q);
147
+ return true;
148
+ } catch {
149
+ return false;
150
+ }
151
+ };
152
+ while (cur && cur !== '/' && !existsOrLink(cur)) {
153
+ rest = `/${path.basename(cur)}${rest}`;
154
+ const parent = cur.slice(0, cur.lastIndexOf('/')) || (cur.startsWith('/') ? '/' : cur);
155
+ if (parent === cur) break;
156
+ cur = parent;
157
+ }
158
+ if (!cur) cur = '/';
159
+ try {
160
+ const out = fs.realpathSync.native(cur);
161
+ return `${out}${rest}`;
162
+ } catch {
163
+ return '';
164
+ }
165
+ }
166
+
167
+ function evaluate({ command, projectRoot, env }) {
168
+ if (!command) return { code: 0, stdout: '', stderr: '' };
169
+
170
+ // Quoted-span scrub: only when the invoked command is a pure text tool AND
171
+ // its output cannot reach a shell — `echo "rm -rf /" | bash` executes the
172
+ // quoted text, so it must never be scrubbed.
173
+ const head = (command.trimStart().split(/\s+/)[0] || '');
174
+ const cmdHead = basename(head);
175
+ let scan = command;
176
+ if (SAFE_TEXT_HEADS.has(cmdHead) && !PIPE_TO_SHELL_RE.test(command)) {
177
+ scan = command.replace(/'[^']*'/g, "'Q'").replace(/"[^"]*"/g, '"Q"');
178
+ }
179
+
180
+ for (const literal of DANGEROUS_LITERALS) {
181
+ if (scan.includes(literal)) return emitDangerous(REASON_DANGEROUS, env);
182
+ }
183
+ for (const re of DANGEROUS_REGEXES) {
184
+ if (re.test(scan)) return emitDangerous(REASON_DANGEROUS, env);
185
+ }
186
+
187
+ // rm handling: allow safe cleanup targets only; everything else defers.
188
+ const projectRootCanon = canonicalPath(projectRoot, env);
189
+
190
+ let verdictUnsafe = false;
191
+ let verdictGeneric = false;
192
+ let directSeen = false;
193
+
194
+ for (const rawSegment of scan.split(/[;&|\n]/)) {
195
+ const segment = rawSegment;
196
+ if (!segment) continue;
197
+ const tokens = segment.trimStart().split(/\s+/).filter(Boolean);
198
+ if (basename(tokens[0] || '') !== 'rm') continue;
199
+
200
+ // flags: leading -x tokens until `--` or the first non-flag word.
201
+ let hasR = false;
202
+ let hasF = false;
203
+ let i = 1;
204
+ for (; i < tokens.length; i += 1) {
205
+ const t = tokens[i];
206
+ if (t === '--') break;
207
+ if (!t.startsWith('-')) break;
208
+ if (t === '--recursive') hasR = true;
209
+ else if (t === '--force') hasF = true;
210
+ else if (t.startsWith('--')) { /* other long flag: ignore */ }
211
+ else {
212
+ if (/[rR]/.test(t)) hasR = true;
213
+ if (t.includes('f')) hasF = true;
214
+ }
215
+ }
216
+ if (!(hasR && hasF)) continue;
217
+ directSeen = true;
218
+
219
+ // targets: the first non-flag word and every later non-dash word.
220
+ const targets = [];
221
+ {
222
+ let saw = false;
223
+ for (let j = 1; j < tokens.length; j += 1) {
224
+ const t = tokens[j];
225
+ if (!saw) {
226
+ if (t === '--') {
227
+ saw = true;
228
+ continue;
229
+ }
230
+ if (t.startsWith('-')) continue;
231
+ saw = true;
232
+ targets.push(t);
233
+ } else if (!t.startsWith('-')) {
234
+ targets.push(t);
235
+ }
236
+ }
237
+ }
238
+
239
+ let allSafe = true;
240
+ let anyUnsafe = false;
241
+ for (let target of targets) {
242
+ // Strip ONE trailing slash (never shrink a bare `/` or `~` to '').
243
+ if (target.length > 1 && target.endsWith('/')) target = target.slice(0, -1);
244
+ if (UNSAFE_ONE_TARGET_RE.test(target)) {
245
+ anyUnsafe = true;
246
+ allSafe = false;
247
+ } else if (SAFE_ONE_TARGET_RE.test(target)) {
248
+ // TASK-013 containment gate: the allowlisted NAME is not enough — the
249
+ // canonical target must live inside the canonical project root.
250
+ if (target.includes('..')) {
251
+ allSafe = false;
252
+ } else {
253
+ const abs = target.startsWith('/') ? target : `${projectRootCanon}/${target}`;
254
+ const canon = canonicalPath(abs, env);
255
+ if (!projectRootCanon || !canon || !canon.startsWith(`${projectRootCanon}/`)) {
256
+ allSafe = false;
257
+ }
258
+ }
259
+ } else {
260
+ allSafe = false;
261
+ }
262
+ }
263
+ if (anyUnsafe) verdictUnsafe = true;
264
+ else if (!allSafe) verdictGeneric = true;
265
+ }
266
+
267
+ if (!directSeen && RM_WORD_RE.test(scan) && HAS_RECURSIVE_RE.test(scan) && HAS_FORCE_RE.test(scan)) {
268
+ // Whole-command fallback (wrapped/quoted rm, flags after targets): unsafe
269
+ // wins over the allowlist — a safe word elsewhere never legitimises an
270
+ // absolute/home/parent target we can also see.
271
+ if (UNSAFE_TARGET_RE.test(scan)) verdictUnsafe = true;
272
+ else if (!SAFE_DELETE_RE.test(scan)) verdictGeneric = true;
273
+ }
274
+
275
+ if (verdictUnsafe) return emitDangerous(REASON_UNSAFE_TARGET, env);
276
+ if (verdictGeneric) return emitDangerous(REASON_GENERIC_RM, env);
277
+ return { code: 0, stdout: '', stderr: '' };
278
+ }
279
+
280
+ // --- SPEC §8 chain-step entry --------------------------------------------------
281
+ export async function runHook(ctx) {
282
+ const env = (ctx && ctx.env) || {};
283
+ const projectRoot =
284
+ env.CLAUDE_PROJECT_DIR || (ctx && ctx.projectRoot) || env.PROJECT_ROOT || process.cwd();
285
+ const deadlineMs =
286
+ Number.parseInt((ctx && ctx.deadlineMs) ?? env.UKIT_HOOK_DEADLINE_MS ?? '', 10) || 3000;
287
+
288
+ const rawInput =
289
+ typeof (ctx && ctx.payloadText) === 'string' && ctx.payloadText.length > 0
290
+ ? ctx.payloadText
291
+ : (() => {
292
+ try {
293
+ return JSON.stringify((ctx && ctx.payload) ?? {});
294
+ } catch {
295
+ return '';
296
+ }
297
+ })();
298
+
299
+ let extracted = extractCommand({ rawInput, env });
300
+ if (extracted.unparseable) {
301
+ // The .sh only salvages on a staging-degraded flag; in-proc there is no flag
302
+ // — an unparseable payloadText IS the degradation. `{"…`-prefixed text that
303
+ // fails to parse is a truncated payload: salvage tool_input.command only
304
+ // when provably complete, else refuse deny (a partial command is never
305
+ // evaluated). Non-JSON garbage is nothing to scan -> pass, same as jq
306
+ // failing on the .sh path.
307
+ if (rawInput.trimStart().startsWith('{')) {
308
+ const salvaged = salvageField(rawInput, 'tool_input.command');
309
+ if (!salvaged.complete || salvaged.value === null) return emitRefusal(env);
310
+ extracted = { command: salvaged.value };
311
+ } else {
312
+ extracted = { command: '' };
313
+ }
314
+ }
315
+
316
+ let timer;
317
+ const timeout = new Promise((resolve) => {
318
+ timer = setTimeout(() => resolve(TIMEOUT_REFUSAL), Math.max(1, deadlineMs));
319
+ if (timer && typeof timer.unref === 'function') timer.unref();
320
+ });
321
+ try {
322
+ const run = (async () => {
323
+ if (ctx && ctx.signal && ctx.signal.aborted) return TIMEOUT_REFUSAL;
324
+ return evaluate({ command: extracted.command, projectRoot, env });
325
+ })();
326
+ return await Promise.race([run, timeout]);
327
+ } catch {
328
+ return CRASH_REFUSAL;
329
+ } finally {
330
+ clearTimeout(timer);
331
+ }
332
+ }
333
+
334
+ // --- direct-run CLI mode (the .sh thin wrapper's target) -----------------------
335
+ export function isDirectRun() {
336
+ try {
337
+ const invoked = process.argv[1] ? fs.realpathSync(process.argv[1]) : '';
338
+ const self = fs.realpathSync(fileURLToPath(import.meta.url));
339
+ return invoked === self;
340
+ } catch {
341
+ return false;
342
+ }
343
+ }
344
+
345
+ async function main() {
346
+ const env = process.env;
347
+ const HOOK_DEADLINE_MS = Number.parseInt(env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
348
+ // .sh parity: the deadline bounded the extraction node — expiry produced an
349
+ // empty command and a pass (exit 0, no output).
350
+ const timer = setTimeout(() => process.exit(0), HOOK_DEADLINE_MS);
351
+ if (timer && typeof timer.unref === 'function') timer.unref();
352
+
353
+ let rawInput = '';
354
+ try {
355
+ rawInput = await fsp.readFile(env.INPUT_FILE || '', 'utf8');
356
+ } catch {}
357
+ const projectRoot = env.PROJECT_ROOT || env.CLAUDE_PROJECT_DIR || process.cwd();
358
+
359
+ try {
360
+ const extracted = extractCommand({ rawInput, env });
361
+ const command = extracted.unparseable ? '' : extracted.command;
362
+ const verdict = evaluate({ command, projectRoot, env });
363
+ if (verdict.stdout) process.stdout.write(verdict.stdout);
364
+ if (verdict.stderr) process.stderr.write(verdict.stderr);
365
+ process.exit(verdict.code);
366
+ } catch {
367
+ // Fail-closed (guard parity): a crash means the payload was never
368
+ // inspected — refuse, never a silent non-blocking hook error.
369
+ if (CRASH_REFUSAL.stdout) process.stdout.write(CRASH_REFUSAL.stdout);
370
+ if (CRASH_REFUSAL.stderr) process.stderr.write(CRASH_REFUSAL.stderr);
371
+ process.exit(CRASH_REFUSAL.code);
372
+ }
373
+ }
374
+
375
+ if (isDirectRun()) {
376
+ main();
377
+ }