@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.
@@ -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 `