@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 +38 -0
- package/manifests/platform.full.yaml +43 -0
- package/package.json +1 -1
- package/templates/.claude/hooks/block-dangerous.mjs +377 -0
- package/templates/.claude/hooks/block-dangerous.sh +24 -223
- package/templates/.claude/hooks/record-execution.mjs +141 -0
- package/templates/.claude/hooks/record-execution.sh +14 -9
- package/templates/.claude/hooks/sensitive-data-guard.mjs +513 -0
- package/templates/.claude/hooks/sensitive-data-guard.sh +1 -353
- package/templates/.claude/settings.json +6 -6
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +17 -1
- package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +134 -26
- package/templates/.claude/ukit/runtime/hook-field-salvage.mjs +119 -0
- package/templates/.omp/hooks/pre/ukit-bridge.js +20 -12
|
@@ -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.
|
|
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.
|
|
47
|
-
'sensitive-data-guard.
|
|
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.
|
|
55
|
-
'Edit|Write': ['post-edit-verify.sh', 'record-execution.
|
|
56
|
-
Bash: ['compress-output.sh', 'record-execution.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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 `
|