@ngockhoale/ukit 2.7.3 → 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.
@@ -2,13 +2,14 @@
2
2
 
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
5
6
  // TASK-016: children run through the process-tree runner instead of bare
6
7
  // spawnSync — a timed-out child is TERM→KILLed as a whole POSIX process group,
7
8
  // so a bash wrapper can no longer orphan the Node grandchildren it spawned.
8
9
  import { runHookProcess } from './hook-process.mjs';
9
10
  // TASK-019: chain rows and direct-hook rows share one versioned schema and one
10
11
  // append API (hook-telemetry.mjs) — including safeName for session files.
11
- import { appendTelemetryRow, TELEMETRY_VERSION } from './hook-telemetry.mjs';
12
+ import { appendTelemetryRow, recordHookTiming, TELEMETRY_VERSION } from './hook-telemetry.mjs';
12
13
  // TASK-018 review fix round 1: the chain budget is resolved by ONE shared module
13
14
  // so the runner's inner deadline and the bridge's outer pi.exec timeout can never
14
15
  // disagree. Kept as a per-run resolution (not module constants) so an operator's
@@ -26,6 +27,12 @@ const FAIL_CLOSED_SCRIPTS = new Set([
26
27
  'handoff-model-guard.sh',
27
28
  'context-hardcap-gate.sh',
28
29
  'block-dangerous.sh',
30
+ // TASK-001 (SPEC §FR-002): in-proc module steps join the fail-closed registry.
31
+ // The runner's static set is authoritative for .mjs steps — an import that
32
+ // never finishes cannot declare `hookFailClosed`. Basenames only; the `:N`
33
+ // suffix is stripped before lookup by the same parser used for .sh args.
34
+ 'sensitive-data-guard.mjs',
35
+ 'block-dangerous.mjs',
29
36
  ]);
30
37
 
31
38
  const MAX_BUFFER_BYTES = 2 * 1024 * 1024;
@@ -110,6 +117,67 @@ function parseScriptArg(arg) {
110
117
  return { scriptPath: match[1], timeoutMs: Math.round(seconds * 1000) };
111
118
  }
112
119
 
120
+ // TASK-001 (SPEC §FR-001, §8): a chain arg ending in `.mjs` is an IN-PROC step —
121
+ // `await import()` + `mod.runHook(ctx)` instead of a spawned child. The result
122
+ // is shaped like a hook-process result so `chainFailureKind` and the results[]
123
+ // entry stay identical: throw → failureKind 'error'; deadline → 'deadline' →
124
+ // 'timeout'. The deadline is enforced by Promise.race + AbortSignal; the
125
+ // rejection race keeps running in the background (in-proc code cannot be
126
+ // SIGKILLed) but its late return value is discarded.
127
+ async function runModuleStep({ scriptPath, payload, payloadText, projectRoot, env, deadlineMs }) {
128
+ const controller = new AbortController();
129
+ let timer;
130
+ const timeout = new Promise((resolve) => {
131
+ timer = setTimeout(() => {
132
+ controller.abort();
133
+ resolve({ failureKind: 'deadline' });
134
+ }, Math.max(1, deadlineMs));
135
+ });
136
+ const invoke = (async () => {
137
+ // FR-001: module-level self-deadlines that call process.exit must never arm
138
+ // inside the runner — scrub the env var before the import executes module
139
+ // top-level code AND from the ctx.env the step inspects. The deadline is
140
+ // passed explicitly via ctx.deadlineMs/ctx.signal.
141
+ const scrubbed = 'UKIT_HOOK_DEADLINE_MS' in process.env
142
+ ? process.env.UKIT_HOOK_DEADLINE_MS : undefined;
143
+ delete process.env.UKIT_HOOK_DEADLINE_MS;
144
+ try {
145
+ const mod = await import(pathToFileURL(scriptPath).href);
146
+ const childEnv = { ...env };
147
+ delete childEnv.UKIT_HOOK_DEADLINE_MS;
148
+ const out = await mod.runHook({
149
+ payload,
150
+ payloadText,
151
+ projectRoot,
152
+ env: childEnv,
153
+ deadlineMs,
154
+ signal: controller.signal,
155
+ });
156
+ return {
157
+ code: Number.isFinite(out?.code) ? out.code : 1,
158
+ stdout: typeof out?.stdout === 'string' ? out.stdout : '',
159
+ stderr: typeof out?.stderr === 'string' ? out.stderr : '',
160
+ };
161
+ } finally {
162
+ // Skip the restore once this step was aborted: the race already returned,
163
+ // so this finally can run while the NEXT step is mid-import — restoring
164
+ // the var then would arm that module's top-level self-deadline inside the
165
+ // runner. Every step re-scrubs before its own import, so leaking the
166
+ // deletion is safe.
167
+ if (scrubbed !== undefined && !controller.signal.aborted) {
168
+ process.env.UKIT_HOOK_DEADLINE_MS = scrubbed;
169
+ }
170
+ }
171
+ })();
172
+ try {
173
+ return await Promise.race([invoke, timeout]);
174
+ } catch (error) {
175
+ return { failureKind: 'error', stderr: error?.message || String(error) };
176
+ } finally {
177
+ clearTimeout(timer);
178
+ }
179
+ }
180
+
113
181
  async function run(payloadText, scriptArgs, { chainMarker = true } = {}) {
114
182
  const parsedArgs = scriptArgs.map(parseScriptArg);
115
183
  const scriptPaths = parsedArgs.map((a) => a.scriptPath);
@@ -176,28 +244,47 @@ async function run(payloadText, scriptArgs, { chainMarker = true } = {}) {
176
244
  }
177
245
 
178
246
  const childStartedAt = Date.now();
179
- const result = await runHookProcess({
180
- command: scriptPath,
181
- deadlineMs: Math.min(childBudgetsMs[scriptIndex], remainingMs),
182
- args: [],
183
- input: payloadText,
184
- maxBuffer: MAX_BUFFER_BYTES,
185
- cwd: projectRoot,
186
- // TASK-223 (HK-401): mark chain-spawned children so their structured
187
- // permission decisions keep the omp contract (stdout parsed on exit 2).
188
- // Direct Claude Code invocations carry no marker and exit 0 instead —
189
- // a non-zero exit there discards stdout, killing the decision JSON.
190
- env: {
191
- ...process.env,
192
- CLAUDE_PROJECT_DIR: projectRoot,
193
- // TASK-234: the marker selects the omp structured-decision contract
194
- // (ask + exit 2). Under --emit-verdict the runner replays the direct
195
- // Claude contract (deny + exit 0), so children must NOT see it.
196
- ...(chainMarker ? { UKIT_HOOK_CHAIN_RUNNER: '1' } : {}),
197
- },
198
- });
247
+ const stepEnv = {
248
+ ...process.env,
249
+ CLAUDE_PROJECT_DIR: projectRoot,
250
+ // TASK-234: the marker selects the omp structured-decision contract
251
+ // (ask + exit 2). Under --emit-verdict the runner replays the direct
252
+ // Claude contract (deny + exit 0), so children must NOT see it.
253
+ ...(chainMarker ? { UKIT_HOOK_CHAIN_RUNNER: '1' } : {}),
254
+ };
255
+ const stepDeadlineMs = Math.min(childBudgetsMs[scriptIndex], remainingMs);
256
+ // TASK-001: `.mjs` args run in-proc via runHook(ctx); `.sh` stays a child
257
+ // process. Both paths share deadline/budget, results[] shape, and break
258
+ // conditions.
259
+ const result = scriptPath.endsWith('.mjs')
260
+ ? await runModuleStep({
261
+ scriptPath,
262
+ payload,
263
+ payloadText,
264
+ projectRoot,
265
+ env: stepEnv,
266
+ deadlineMs: stepDeadlineMs,
267
+ })
268
+ : await runHookProcess({
269
+ command: scriptPath,
270
+ deadlineMs: stepDeadlineMs,
271
+ args: [],
272
+ input: payloadText,
273
+ maxBuffer: MAX_BUFFER_BYTES,
274
+ cwd: projectRoot,
275
+ // TASK-223 (HK-401): mark chain-spawned children so their structured
276
+ // permission decisions keep the omp contract (stdout parsed on
277
+ // exit 2). Direct Claude Code invocations carry no marker and exit 0
278
+ // instead — a non-zero exit there discards stdout, killing the
279
+ // decision JSON.
280
+ env: stepEnv,
281
+ });
199
282
  const failureKind = chainFailureKind(result);
200
- const code = Number.isFinite(result.code) ? result.code : 1;
283
+ // FR-001: on infra failure (error/timeout — no module verdict) the result
284
+ // code is 2 when the step is fail-closed, else 1.
285
+ const code = Number.isFinite(result.code)
286
+ ? result.code
287
+ : (FAIL_CLOSED_SCRIPTS.has(scriptName) ? 2 : 1);
201
288
  // `killed` keeps its old meaning for the bridge: the child was stopped before
202
289
  // a natural exit. The DISTINCT cause lives in failureKind — an overflowing
203
290
  // child is no longer reported as a generic kill or a timeout.
@@ -218,6 +305,22 @@ async function run(payloadText, scriptArgs, { chainMarker = true } = {}) {
218
305
  elapsedMs: Date.now() - childStartedAt,
219
306
  });
220
307
 
308
+ // TASK-001 (FR-003): `.sh` children write their own hook-latency row via
309
+ // hook-telemetry.sh --finish; in-proc `.mjs` steps cannot — the runner
310
+ // emits the equivalent row per module step (hook = basename incl. `.mjs`).
311
+ if (scriptPath.endsWith('.mjs')) {
312
+ recordHookTiming({
313
+ projectRoot,
314
+ sessionId: payload?.session_id,
315
+ event: payload?.hook_event_name || null,
316
+ tool: payload?.tool_name || null,
317
+ toolUseId: payload?.tool_use_id || null,
318
+ hook: scriptName,
319
+ elapsedMs: Date.now() - childStartedAt,
320
+ outcome: failureKind,
321
+ });
322
+ }
323
+
221
324
  if (code === 2 || killed || (code !== 0 && FAIL_CLOSED_SCRIPTS.has(scriptName))) {
222
325
  break;
223
326
  }
@@ -371,15 +474,20 @@ try {
371
474
  } else {
372
475
  const lastIsFailClosed = FAIL_CLOSED_SCRIPTS.has(last.scriptName);
373
476
  const lastFailedToVerdict = last.killed || last.failureKind === 'error' || last.failureKind === 'budget-exhausted';
374
- if (last.code === 2) {
477
+ // Check failed-to-verdict BEFORE `last.code === 2`: infra failures on a
478
+ // fail-closed step now synthesize code 2, so a killed gate would
479
+ // otherwise take the plain code-2 branch and lose the "(<failureKind>)"
480
+ // diagnostic. A REAL code 2 has no killed/error/budget failureKind, so
481
+ // the reorder can't misroute a legitimate block.
482
+ if (lastIsFailClosed && lastFailedToVerdict) {
375
483
  if (contextStdout) process.stdout.write(contextStdout);
376
- if (last.stdout) process.stdout.write(last.stdout);
377
484
  if (last.stderr) process.stderr.write(last.stderr);
485
+ else process.stderr.write(`UKit fail-closed hook ${last.scriptName} could not produce a verdict (${last.failureKind})\n`);
378
486
  process.exitCode = 2;
379
- } else if (lastIsFailClosed && lastFailedToVerdict) {
487
+ } else if (last.code === 2) {
380
488
  if (contextStdout) process.stdout.write(contextStdout);
489
+ if (last.stdout) process.stdout.write(last.stdout);
381
490
  if (last.stderr) process.stderr.write(last.stderr);
382
- else process.stderr.write(`UKit fail-closed hook ${last.scriptName} could not produce a verdict (${last.failureKind})\n`);
383
491
  process.exitCode = 2;
384
492
  } else {
385
493
  if (contextStdout) process.stdout.write(contextStdout);
@@ -0,0 +1,119 @@
1
+ // hook-field-salvage.mjs — recover a JSON string field from truncated payload
2
+ // text. Shared helper (SPEC C40 §FR-007): ports ukit_salvage_tool_field from
3
+ // hook-input.sh so in-proc module steps (sensitive-data-guard.mjs now,
4
+ // block-dangerous.mjs in TASK-003) can salvage a decision-relevant field without
5
+ // spawning the bash salvage path.
6
+ //
7
+ // Contract:
8
+ // salvageField(text, dotPath) -> { complete: boolean, value: string|null }
9
+ // complete:true — the field's string value was fully present: its closing
10
+ // quote AND a following `,`/`}` boundary appear before the
11
+ // cut; `value` is the JSON-decoded string.
12
+ // complete:false — unrecoverable: missing key, non-string leaf, cut inside
13
+ // the string, EOF right after the quote, malformed prefix.
14
+ // `value` is null. Callers map this to fail-closed — a
15
+ // partial field is never scanned (a truncated dangerous
16
+ // shape could parse benign = fail-open).
17
+ // Single string fields only — this is a salvage step, not a JSON repairer.
18
+
19
+ const isWs = (c) => c === ' ' || c === '\t' || c === '\n' || c === '\r';
20
+ const skipWs = (s, i) => {
21
+ while (i < s.length && isWs(s[i])) i += 1;
22
+ return i;
23
+ };
24
+
25
+ // End index of the string literal starting at `start` (which must be `"`), or -1
26
+ // when the string is cut before its closing quote.
27
+ const scanStringEnd = (s, start) => {
28
+ for (let i = start + 1; i < s.length; i += 1) {
29
+ const c = s[i];
30
+ if (c === '\\') {
31
+ i += 1;
32
+ continue;
33
+ }
34
+ if (c === '"') return i;
35
+ }
36
+ return -1;
37
+ };
38
+
39
+ // Closing brace matching the `{` at `open` (string-aware), or -1 if unclosed.
40
+ const matchBrace = (s, open) => {
41
+ let depth = 0;
42
+ for (let i = open; i < s.length; i += 1) {
43
+ const c = s[i];
44
+ if (c === '"') {
45
+ const end = scanStringEnd(s, i);
46
+ if (end === -1) return -1;
47
+ i = end;
48
+ continue;
49
+ }
50
+ if (c === '{') depth += 1;
51
+ else if (c === '}') {
52
+ depth -= 1;
53
+ if (depth === 0) return i;
54
+ }
55
+ }
56
+ return -1;
57
+ };
58
+
59
+ // Find `"key"` used as an object key inside region [lo, hi); returns the index
60
+ // of its `:` or -1. A bare `"key"` inside a string value cannot produce this
61
+ // shape (its quotes are escaped), and non-key uses lack the `:` — both are
62
+ // skipped by scanning forward.
63
+ const findKey = (s, key, lo, hi) => {
64
+ const needle = `"${key}"`;
65
+ let pos = s.indexOf(needle, lo);
66
+ while (pos !== -1 && pos < hi) {
67
+ const colon = skipWs(s, pos + needle.length);
68
+ if (colon < hi && colon < s.length && s[colon] === ':') {
69
+ const prev = pos - 1;
70
+ const pc = prev >= 0 ? s[prev] : '';
71
+ if (prev < 0 || pc === '{' || pc === ',' || isWs(pc)) return colon;
72
+ }
73
+ pos = s.indexOf(needle, pos + 1);
74
+ }
75
+ return -1;
76
+ };
77
+
78
+ const INCOMPLETE = { complete: false, value: null };
79
+
80
+ export function salvageField(text, dotPath) {
81
+ const data = typeof text === 'string' ? text : '';
82
+ const dotted = String(dotPath || '')
83
+ .split('.')
84
+ .filter(Boolean);
85
+ if (!data || data[0] !== '{' || dotted.length === 0 || dotted.length > 4) return INCOMPLETE;
86
+
87
+ let regionLo = 0;
88
+ let regionHi = data.length;
89
+ for (let k = 0; k < dotted.length; k += 1) {
90
+ const colon = findKey(data, dotted[k], regionLo, regionHi);
91
+ if (colon === -1) return INCOMPLETE;
92
+ const vstart = skipWs(data, colon + 1);
93
+ if (vstart >= data.length) return INCOMPLETE;
94
+ const last = k === dotted.length - 1;
95
+ const c = data[vstart];
96
+ if (!last) {
97
+ if (c !== '{') return INCOMPLETE;
98
+ const close = matchBrace(data, vstart);
99
+ // An unclosed parent object still bounds the search to what arrived; the
100
+ // leaf's own boundary proof below decides completeness.
101
+ regionLo = vstart + 1;
102
+ regionHi = close === -1 ? data.length : close;
103
+ continue;
104
+ }
105
+ if (c !== '"') return INCOMPLETE; // string fields only
106
+ const end = scanStringEnd(data, vstart);
107
+ if (end === -1) return INCOMPLETE; // cut inside the value — never trust a partial field
108
+ const after = skipWs(data, end + 1);
109
+ if (after >= data.length) return INCOMPLETE; // closed quote but no boundary proof — err closed
110
+ const boundary = data[after];
111
+ if (boundary !== ',' && boundary !== '}') return INCOMPLETE;
112
+ try {
113
+ return { complete: true, value: JSON.parse(data.slice(vstart, end + 1)) };
114
+ } catch {
115
+ return INCOMPLETE;
116
+ }
117
+ }
118
+ return INCOMPLETE;
119
+ }
@@ -32,7 +32,7 @@ import { resolveChainExecTimeoutMs } from '../../../.claude/ukit/runtime/hook-ch
32
32
 
33
33
  export const HOOK_EVENT_MAP = {
34
34
  tool_call: {
35
- 'Read|Grep|Glob': ['sensitive-data-guard.sh'],
35
+ 'Read|Grep|Glob': ['sensitive-data-guard.mjs'],
36
36
  'Edit|Write': [
37
37
  'protect-files.sh',
38
38
  'stale-spec-guard.sh',
@@ -43,19 +43,19 @@ export const HOOK_EVENT_MAP = {
43
43
  ],
44
44
  Bash: [
45
45
  'auto-allow-bash.sh',
46
- 'block-dangerous.sh',
47
- 'sensitive-data-guard.sh',
46
+ 'block-dangerous.mjs',
47
+ 'sensitive-data-guard.mjs',
48
48
  'handoff-model-guard.sh',
49
49
  'context-hardcap-gate.sh',
50
50
  'verification-guard.sh',
51
51
  ],
52
52
  },
53
53
  tool_result: {
54
- 'Read|Grep|Glob': ['record-execution.sh'],
55
- 'Edit|Write': ['post-edit-verify.sh', 'record-execution.sh', 'task-watchdog.sh'],
56
- Bash: ['compress-output.sh', 'record-execution.sh'],
54
+ 'Read|Grep|Glob': ['record-execution.mjs'],
55
+ 'Edit|Write': ['post-edit-verify.sh', 'record-execution.mjs', 'task-watchdog.sh'],
56
+ Bash: ['compress-output.sh', 'record-execution.mjs'],
57
57
  },
58
- before_agent_start: ['sensitive-data-guard.sh', 'skill-router.sh', 'vision-router.sh', 'context-window-guard.sh'],
58
+ before_agent_start: ['sensitive-data-guard.mjs', 'skill-router.sh', 'vision-router.sh', 'context-window-guard.sh'],
59
59
  'session.compacting': ['reinject-context.sh'],
60
60
  session_start: ['project-important.sh', 'auto-prune-bash.sh', 'reset-compact-pressure.sh', 'handoff-resume.sh'],
61
61
  };
@@ -99,12 +99,18 @@ export const FAIL_CLOSED_SCRIPTS = new Set([
99
99
  'context-hardcap-gate.sh',
100
100
  'block-dangerous.sh',
101
101
  'sensitive-data-guard.sh',
102
+ // TASK-005 (SPEC §FR-008): the three ported hooks now run as in-proc .mjs
103
+ // module steps; the .sh names stay registered for the thin-wrapper fallback.
104
+ 'block-dangerous.mjs',
105
+ 'sensitive-data-guard.mjs',
102
106
  ]);
103
107
 
104
108
  export const ADVISORY_SCRIPTS = new Set([
105
109
  'skill-router.sh',
106
110
  'verification-guard.sh',
107
111
  'record-execution.sh',
112
+ // TASK-005: record-execution now runs as an .mjs module step (advisory).
113
+ 'record-execution.mjs',
108
114
  'auto-allow-bash.sh',
109
115
  'pre-edit-backup.sh',
110
116
  'vision-router.sh',
@@ -130,7 +136,9 @@ function classifyFailure(scriptName) {
130
136
  // must not create. One deliberate exception: block-dangerous.sh is the gate the user
131
137
  // explicitly required to never fail open (destructive-command protection), so a Bash chain
132
138
  // whose dangerous-command check timed out stays blocked with the honest reason.
133
- const TIMEOUT_STAYS_CLOSED = new Set(['block-dangerous.sh']);
139
+ // TASK-005: block-dangerous runs as an .mjs module step now — both names stay
140
+ // closed (the .sh thin wrapper is still invocable as a fallback path).
141
+ const TIMEOUT_STAYS_CLOSED = new Set(['block-dangerous.sh', 'block-dangerous.mjs']);
134
142
 
135
143
  // TASK-018: the hook-chain-runner's failure taxonomy. Infrastructure outcomes
136
144
  // (overflow / timeout / signal / budget-exhausted) produced NO verdict, so their
@@ -272,11 +280,11 @@ function translateExecResult(scriptName, execResult) {
272
280
  // realistic, and that guard fails open. This is deliberate anti-freeze policy:
273
281
  // a guard that produced NO verdict is an infrastructure event, and blocking on
274
282
  // it froze every Edit|Write whenever the machine was slow. Only
275
- // block-dangerous.sh stays closed (TIMEOUT_STAYS_CLOSED) because destructive-
283
+ // block-dangerous (.sh/.mjs) stays closed (TIMEOUT_STAYS_CLOSED) because destructive-
276
284
  // command protection was explicitly required to never fail open. Stated in the
277
285
  // warning so a skipped guard is never a silent one.
278
286
  const failOpenTradeOff = FAIL_CLOSED_SCRIPTS.has(scriptName) && !TIMEOUT_STAYS_CLOSED.has(scriptName)
279
- ? ` ${scriptName} is a fail-closed guard, but a guard that never produced a verdict is treated as "could not verify" rather than a block — the deliberate anti-freeze trade-off for infrastructure events; only block-dangerous.sh stays closed.`
287
+ ? ` ${scriptName} is a fail-closed guard, but a guard that never produced a verdict is treated as "could not verify" rather than a block — the deliberate anti-freeze trade-off for infrastructure events; only block-dangerous (.sh/.mjs) stays closed.`
280
288
  : '';
281
289
  return {
282
290
  block: false,
@@ -590,11 +598,11 @@ export async function runScriptChain(
590
598
  recordHookErrorDiagnostic(projectRoot, payload.session_id, diagnostic);
591
599
  // TASK-031: a staged payload that was lost or corrupted mid-chain voids every
592
600
  // verdict below it. Chains that must not fail open on an unverifiable verdict
593
- // (Edit|Write transport policy, and block-dangerous.sh's never-fail-open rule)
601
+ // (Edit|Write transport policy, and block-dangerous's never-fail-open rule)
594
602
  // stay closed; everything else fails open loudly. Neither reason carries any
595
603
  // payload content — only the classification and byte count.
596
604
  const payloadStaysClosed = payloadProbe !== null
597
- && (failClosedOnTransportError || scripts.includes('block-dangerous.sh'));
605
+ && (failClosedOnTransportError || scripts.includes('block-dangerous.sh') || scripts.includes('block-dangerous.mjs'));
598
606
  if (payloadStaysClosed) {
599
607
  const integrityReason = `UKit hook payload transport failed: the staged payload file was `
600
608
  + `${payloadProbe === 'missing' ? 'removed' : 'truncated'} before the chain could read it `