@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.
- package/CHANGELOG.md +66 -0
- package/manifests/platform.full.yaml +43 -0
- package/package.json +1 -1
- package/src/cli/commands/doctor.js +14 -0
- package/src/core/hookChainDoctor.js +149 -0
- 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/hooks/session-episode.sh +26 -1
- 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
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,72 @@
|
|
|
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
|
+
|
|
43
|
+
## 2.7.4 - 2026-09-20
|
|
44
|
+
|
|
45
|
+
Deferred hook findings + residual risks — cycle C39 (TASK-001..005).
|
|
46
|
+
|
|
47
|
+
- `session-episode.sh` held-stdin stall fixed twice over: gate-first
|
|
48
|
+
ordering — a pre-staging node probe evaluates
|
|
49
|
+
`learning.episodes.autoWrite === true`; gate off / missing / corrupt
|
|
50
|
+
config or absent node exits 0 immediately without touching stdin
|
|
51
|
+
(~2084ms → ~14–40ms). The gate-on staged-read + `ukit memory episode`
|
|
52
|
+
flow is unchanged. The gate probe itself is armed with an unref'd
|
|
53
|
+
`UKIT_HOOK_DEADLINE_MS` deadline (default 2s → exit 0, gate-off safe
|
|
54
|
+
default) so a wedged probe can never re-create the stall it prevents.
|
|
55
|
+
- SessionEnd residual spawn cost (`chain-sessionend-all`) resolved: the
|
|
56
|
+
background watchdog subshell in `session-episode.sh` inherited the
|
|
57
|
+
child's stdout/stderr pipes, blocking captured-stdio callers ~2s after
|
|
58
|
+
script exit. Watchdog now detaches stdio (`>/dev/null 2>&1 &`) —
|
|
59
|
+
residual 2050ms → ~40ms median; telemetry, per-script `:N` budgets,
|
|
60
|
+
and fail-open posture preserved.
|
|
61
|
+
- Regression guard `chainTimeoutSuffixes`: every `"<path>.sh":N` budget
|
|
62
|
+
suffix in template + installed `settings.json` is pinned against the
|
|
63
|
+
script's original standalone timeout — drift in either direction
|
|
64
|
+
(wrong N or missing suffix) fails the suite.
|
|
65
|
+
- `ukit doctor` gains a `Hook-chain checks` section
|
|
66
|
+
(`src/core/hookChainDoctor.js`): bounded scan of recent
|
|
67
|
+
`hook-latency/*.jsonl` rows reports fail-closed outcomes
|
|
68
|
+
(timeout/error) as an advisory warning with remedy — never blocks,
|
|
69
|
+
exit code untouched.
|
|
70
|
+
|
|
5
71
|
## 2.7.3 - 2026-09-20
|
|
6
72
|
|
|
7
73
|
AI freeze audit remediation — cycle C38 (TASK-001..007).
|
|
@@ -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
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
import { runDocContractChecks } from '../../core/docContracts.js';
|
|
26
26
|
import { inspectUnattendedMode } from '../../core/unattendedDoctor.js';
|
|
27
27
|
import { inspectPermissions } from '../../core/permissionDoctor.js';
|
|
28
|
+
import { inspectHookChainHealth } from '../../core/hookChainDoctor.js';
|
|
28
29
|
import { findOpencodeArtifacts, opencodeSteerMessage } from '../../core/opencodeSteer.js';
|
|
29
30
|
|
|
30
31
|
export const DOCTOR_HELP_FLAGS = new Set(['--help', '-h']);
|
|
@@ -316,6 +317,19 @@ export async function runDoctor({ packageRoot, projectRoot, argv = [], homeDir =
|
|
|
316
317
|
const permissionReport = await inspectPermissions({ projectRoot, ompPath });
|
|
317
318
|
printPermissionSection(permissionReport, { verbose: argv.includes('--permissions') });
|
|
318
319
|
|
|
320
|
+
// TASK-004 / FR-005 — hook-chain failure-taxonomy watch. Advisory only:
|
|
321
|
+
// warnings render as ✗ + (warning) but never enter the blocking set below.
|
|
322
|
+
const hookChainHealth = await inspectHookChainHealth({ projectRoot });
|
|
323
|
+
console.log('[UKit] Hook-chain checks:');
|
|
324
|
+
{
|
|
325
|
+
const sev = hookChainHealth.severity === 'warning' ? ' (warning)' : hookChainHealth.severity === 'info' ? ' (info)' : '';
|
|
326
|
+
console.log(`[UKit] ${ok(hookChainHealth.passed)} ${hookChainHealth.label}${sev}`);
|
|
327
|
+
if (hookChainHealth.detail) console.log(`[UKit] detail: ${hookChainHealth.detail}`);
|
|
328
|
+
if (hookChainHealth.remedy && (!hookChainHealth.passed || hookChainHealth.severity === 'warning')) {
|
|
329
|
+
console.log(`[UKit] remedy: ${hookChainHealth.remedy}`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
319
333
|
if (runtimeConfigInspection.errors.length > 0) {
|
|
320
334
|
console.log(`[UKit] Runtime config issues: ${runtimeConfigInspection.errors.join(' | ')}`);
|
|
321
335
|
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
|
|
4
|
+
// TASK-004 / SPEC §FR-005 — production-facing watch over the chain-runner
|
|
5
|
+
// failure taxonomy (report §7 residual risk). Scans recent
|
|
6
|
+
// `.ukit/storage/cache/hook-latency/*.jsonl` rows for `hook-chain-runner`
|
|
7
|
+
// entries and reports non-`ok` outcomes by failureKind.
|
|
8
|
+
//
|
|
9
|
+
// Posture: ADVISORY ONLY. A warning never enters doctor's blocking set —
|
|
10
|
+
// missing/clean telemetry is a pass, non-ok outcomes surface as
|
|
11
|
+
// severity:'warning' so doctor.js prints ✗ + (warning) without flipping the
|
|
12
|
+
// exit code (same convention as permissionDoctor drift checks).
|
|
13
|
+
|
|
14
|
+
const WINDOW_MS = 24 * 60 * 60 * 1000; // last 24h of telemetry files
|
|
15
|
+
const MAX_FILES = 32; // newest session files by mtime
|
|
16
|
+
const MAX_ROWS = 5000; // total rows parsed across all files
|
|
17
|
+
const MAX_BYTES_PER_FILE = 512 * 1024; // tail-read bound per file
|
|
18
|
+
const CHAIN_HOOK = 'hook-chain-runner';
|
|
19
|
+
|
|
20
|
+
// Row outcomes that mean a fail-closed gate tripped or a child script died.
|
|
21
|
+
const FAILURE_OUTCOMES = new Set([
|
|
22
|
+
'timeout',
|
|
23
|
+
'budget-exhausted',
|
|
24
|
+
'output-overflow',
|
|
25
|
+
'error',
|
|
26
|
+
'signal',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
function telemetryDirFor(projectRoot) {
|
|
30
|
+
return path.join(projectRoot, '.ukit', 'storage', 'cache', 'hook-latency');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function recentTelemetryFiles(dir, now) {
|
|
34
|
+
let entries;
|
|
35
|
+
try {
|
|
36
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
37
|
+
} catch {
|
|
38
|
+
return []; // dir absent — unknown, not failure
|
|
39
|
+
}
|
|
40
|
+
const candidates = [];
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
|
|
43
|
+
const filePath = path.join(dir, entry.name);
|
|
44
|
+
try {
|
|
45
|
+
const stat = await fs.stat(filePath);
|
|
46
|
+
candidates.push({ filePath, mtimeMs: stat.mtimeMs, size: stat.size });
|
|
47
|
+
} catch {
|
|
48
|
+
// vanished between readdir/stat — skip
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Bound: only files touched inside the window, newest first, capped.
|
|
52
|
+
return candidates
|
|
53
|
+
.filter((f) => now - f.mtimeMs <= WINDOW_MS)
|
|
54
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
55
|
+
.slice(0, MAX_FILES);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function tailLines(filePath, size) {
|
|
59
|
+
const handle = await fs.open(filePath, 'r');
|
|
60
|
+
try {
|
|
61
|
+
const length = Math.min(size, MAX_BYTES_PER_FILE);
|
|
62
|
+
const start = Math.max(0, size - length);
|
|
63
|
+
const buffer = Buffer.alloc(length);
|
|
64
|
+
await handle.read(buffer, 0, length, start);
|
|
65
|
+
let text = buffer.toString('utf8');
|
|
66
|
+
if (start > 0) {
|
|
67
|
+
// Drop the first (probably partial) line of a tail-read.
|
|
68
|
+
const nl = text.indexOf('\n');
|
|
69
|
+
text = nl >= 0 ? text.slice(nl + 1) : '';
|
|
70
|
+
}
|
|
71
|
+
return text.split('\n').filter((line) => line.length > 0);
|
|
72
|
+
} finally {
|
|
73
|
+
await handle.close();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function inspectHookChainHealth({ projectRoot, now = Date.now() } = {}) {
|
|
78
|
+
const dir = telemetryDirFor(projectRoot);
|
|
79
|
+
const files = await recentTelemetryFiles(dir, now);
|
|
80
|
+
|
|
81
|
+
const counts = { rowsScanned: 0, filesScanned: files.length, ok: 0 };
|
|
82
|
+
let rowsLeft = MAX_ROWS;
|
|
83
|
+
|
|
84
|
+
for (const file of files) {
|
|
85
|
+
if (rowsLeft <= 0) break;
|
|
86
|
+
let lines;
|
|
87
|
+
try {
|
|
88
|
+
lines = await tailLines(file.filePath, file.size);
|
|
89
|
+
} catch {
|
|
90
|
+
continue; // unreadable file — skip, never fatal
|
|
91
|
+
}
|
|
92
|
+
for (const line of lines) {
|
|
93
|
+
if (rowsLeft <= 0) break;
|
|
94
|
+
let row;
|
|
95
|
+
try {
|
|
96
|
+
row = JSON.parse(line);
|
|
97
|
+
} catch {
|
|
98
|
+
continue; // corrupt line ignored — regression guard
|
|
99
|
+
}
|
|
100
|
+
if (row?.hook !== CHAIN_HOOK) continue;
|
|
101
|
+
rowsLeft -= 1;
|
|
102
|
+
counts.rowsScanned += 1;
|
|
103
|
+
const outcome = typeof row.outcome === 'string' && row.outcome.length > 0 ? row.outcome : 'ok';
|
|
104
|
+
counts[outcome] = (counts[outcome] ?? 0) + 1;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const label = 'hook-chain health';
|
|
109
|
+
|
|
110
|
+
if (counts.rowsScanned === 0) {
|
|
111
|
+
return {
|
|
112
|
+
label,
|
|
113
|
+
passed: true,
|
|
114
|
+
failed: false,
|
|
115
|
+
severity: 'info',
|
|
116
|
+
remediationClass: 'advisory',
|
|
117
|
+
detail: 'no telemetry — no hook-chain-runner rows in .ukit/storage/cache/hook-latency/ (unknown, not failure)',
|
|
118
|
+
counts,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const failureKinds = Object.keys(counts)
|
|
123
|
+
.filter((key) => FAILURE_OUTCOMES.has(key) && counts[key] > 0)
|
|
124
|
+
.sort();
|
|
125
|
+
|
|
126
|
+
if (failureKinds.length === 0) {
|
|
127
|
+
return {
|
|
128
|
+
label,
|
|
129
|
+
passed: true,
|
|
130
|
+
failed: false,
|
|
131
|
+
severity: 'info',
|
|
132
|
+
remediationClass: 'advisory',
|
|
133
|
+
detail: `${counts.rowsScanned} chain row(s) scanned across ${counts.filesScanned} file(s) — all outcome:ok`,
|
|
134
|
+
counts,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const breakdown = failureKinds.map((kind) => `${kind}=${counts[kind]}`).join(', ');
|
|
139
|
+
return {
|
|
140
|
+
label,
|
|
141
|
+
passed: false,
|
|
142
|
+
failed: true,
|
|
143
|
+
severity: 'warning',
|
|
144
|
+
remediationClass: 'advisory',
|
|
145
|
+
detail: `${counts.rowsScanned} chain row(s) scanned — fail-closed outcomes: ${breakdown}`,
|
|
146
|
+
remedy: 'Inspect recent rows in .ukit/storage/cache/hook-latency/ for the failing scriptName/failureKind and fix or widen the gate budget.',
|
|
147
|
+
counts,
|
|
148
|
+
};
|
|
149
|
+
}
|