@ngockhoale/ukit 3.4.27 → 3.4.28
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 +18 -0
- package/manifests/documentation.yaml +59 -0
- package/manifests/platform.full.yaml +24 -0
- package/package.json +6 -1
- package/scripts/audit/subagent-cost/metrics.mjs +14 -2
- package/scripts/audit/subagent-cost/reader.mjs +15 -3
- package/scripts/audit/subagent-cost/usage-fields.mjs +82 -0
- package/src/cli/commands/update.js +59 -2
- package/src/core/agentRuntime/context.js +78 -0
- package/src/core/diffPlan.js +100 -102
- package/src/core/observability/emit/sessionBoot.js +14 -0
- package/src/core/runtimeConfigMerge.js +125 -0
- package/src/core/updateSafety.js +174 -0
- package/src/index/contextPack.js +285 -0
- package/src/index/sensitivePath.js +21 -0
- package/src/index/taskRouting.js +3 -0
- package/template_project/.claude/commands/ukit/ci-check.md +53 -0
- package/template_project/.claude/hooks/handoff-model-guard.sh +9 -2
- package/template_project/.claude/hooks/handoff-resume.sh +13 -0
- package/template_project/.claude/hooks/record-execution.mjs +9 -0
- package/template_project/.claude/hooks/skill-router.sh +3 -0
- package/template_project/.claude/ukit/index/ci-check.mjs +665 -0
- package/template_project/.claude/ukit/index/route-task.mjs +3 -0
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +19 -7
- package/template_project/.claude/ukit/runtime/handoff-intent.mjs +8 -0
- package/template_project/.claude/ukit/runtime/loop-state.mjs +131 -0
- package/template_project/.claude/ukit/runtime/observability-emit.mjs +14 -0
- package/template_project/.claude/ukit/runtime/read-advisory.mjs +92 -0
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +33 -1
- package/template_project/.claude/ukit/runtime/usage-capture.mjs +338 -0
- package/template_project/.omp/hooks/pre/ukit-bridge.js +48 -4
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// usage-capture.mjs — C100 MB-1b: SHADOW capture of per-call usage metadata.
|
|
3
|
+
//
|
|
4
|
+
// Attached to the EXISTING Stop event (stop-coordinator.mjs) — no extra hook
|
|
5
|
+
// invocation. Stage-gated by `.ukit/storage/config.json` -> usageCapture.stage;
|
|
6
|
+
// absent / 'off' / anything but 'shadow' emits nothing. Observation only: it
|
|
7
|
+
// never changes a routing, stop or tool decision, makes no network or LLM call.
|
|
8
|
+
//
|
|
9
|
+
// Reads the host transcript INCREMENTALLY: a per-session byte cursor means each
|
|
10
|
+
// Stop reads only bytes appended since the previous Stop, never more than
|
|
11
|
+
// MAX_READ_BYTES. Rows are metadata only — no prompt, response, tool input,
|
|
12
|
+
// credential or credentialId is ever copied.
|
|
13
|
+
//
|
|
14
|
+
// Provenance: every row read from a host transcript is HOST_TRANSCRIPT.
|
|
15
|
+
// providerId / gatewayId are identity metadata copied from explicit record keys;
|
|
16
|
+
// they are NOT evidence of who measured the usage. PROVIDER / GATEWAY would need
|
|
17
|
+
// an authenticated source (none exists yet), so they are never emitted here.
|
|
18
|
+
//
|
|
19
|
+
// Completeness: the capture is bounded, so it can be incomplete. Every gap
|
|
20
|
+
// (skipped history, backlog, truncation/rotation, unparsable usage lines, row
|
|
21
|
+
// cap, log rotation, lock timeout, deadline) is written as a `kind:'coverage'`
|
|
22
|
+
// record and a sticky per-session flag. Consumers must treat a session with any
|
|
23
|
+
// partial coverage as partial and never report its cache hit-rate as complete
|
|
24
|
+
// (see summarizeUsageRows).
|
|
25
|
+
//
|
|
26
|
+
// Delivery is at-least-once with dedupe: a crash between the log append and the
|
|
27
|
+
// cursor write replays the window; rows already present in the bounded log tail
|
|
28
|
+
// are skipped by dedupeKey.
|
|
29
|
+
//
|
|
30
|
+
// Engines: Claude Code transcripts (snake_case usage) are supported; omp's
|
|
31
|
+
// camelCase shape is parsed when handed in but the omp bridge does not call this
|
|
32
|
+
// module yet; Codex is explicitly unsupported.
|
|
33
|
+
|
|
34
|
+
import fs from 'node:fs/promises';
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
import crypto from 'node:crypto';
|
|
37
|
+
import { withAsyncLock } from './async-lock.mjs';
|
|
38
|
+
|
|
39
|
+
export const USAGE_ROW_VERSION = 1;
|
|
40
|
+
export const MAX_READ_BYTES = 1024 * 1024;
|
|
41
|
+
export const MAX_ROWS_PER_STOP = 5000;
|
|
42
|
+
export const MAX_LOG_BYTES = 256 * 1024;
|
|
43
|
+
export const DEDUPE_TAIL_BYTES = 64 * 1024;
|
|
44
|
+
export const CAPTURE_DEADLINE_MS = 250;
|
|
45
|
+
const LOCK_DEADLINE_MS = 150;
|
|
46
|
+
|
|
47
|
+
const CACHE_STATUS = Object.freeze({ ZERO: 'present_zero', POSITIVE: 'present_positive', ABSENT: 'absent', INVALID: 'invalid' });
|
|
48
|
+
const READ_KEYS = ['cache_read_input_tokens', 'cache_read_tokens', 'cacheRead'];
|
|
49
|
+
const WRITE_KEYS = ['cache_creation_input_tokens', 'cache_creation_tokens', 'cacheWrite'];
|
|
50
|
+
const SAFE_ID = /^[A-Za-z0-9._:/@-]{1,128}$/;
|
|
51
|
+
const SECRET_LIKE = /sk-[A-Za-z0-9]{8,}|bearer|secret|passw|token=|key=|api[_-]?key/i;
|
|
52
|
+
const PROVIDER_KEYS = ['providerId', 'provider_id', 'provider', 'apiProvider'];
|
|
53
|
+
const GATEWAY_KEYS = ['gatewayId', 'gateway_id', 'gatewayName', 'gateway'];
|
|
54
|
+
|
|
55
|
+
export function resolveUsageCaptureStage(config) {
|
|
56
|
+
return config?.usageCapture?.stage === 'shadow' ? 'shadow' : 'off';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function classifyCache(usage, keys) {
|
|
60
|
+
if (!usage || typeof usage !== 'object') return { status: CACHE_STATUS.ABSENT, value: null };
|
|
61
|
+
for (const key of keys) {
|
|
62
|
+
const raw = usage[key];
|
|
63
|
+
if (raw === undefined || raw === null) continue;
|
|
64
|
+
if (typeof raw !== 'number' || !Number.isFinite(raw) || raw < 0) return { status: CACHE_STATUS.INVALID, value: null };
|
|
65
|
+
return { status: raw === 0 ? CACHE_STATUS.ZERO : CACHE_STATUS.POSITIVE, value: raw };
|
|
66
|
+
}
|
|
67
|
+
return { status: CACHE_STATUS.ABSENT, value: null };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function cacheMetadataStatus(read, write) {
|
|
71
|
+
if (read.status === CACHE_STATUS.INVALID || write.status === CACHE_STATUS.INVALID) return CACHE_STATUS.INVALID;
|
|
72
|
+
if (read.status === CACHE_STATUS.ABSENT) return CACHE_STATUS.ABSENT;
|
|
73
|
+
return read.status;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function safeId(record, message, keys) {
|
|
77
|
+
for (const holder of [message, record]) {
|
|
78
|
+
for (const key of keys) {
|
|
79
|
+
const value = holder?.[key];
|
|
80
|
+
if (typeof value !== 'string') continue;
|
|
81
|
+
return SAFE_ID.test(value) && !SECRET_LIKE.test(value) ? value : null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const count = (v) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : null);
|
|
88
|
+
const hash = (s) => crypto.createHash('sha1').update(s).digest('hex').slice(0, 16);
|
|
89
|
+
|
|
90
|
+
export function buildUsageRow(record, { harness = 'claude-code', now = Date.now(), line = null } = {}) {
|
|
91
|
+
if (!record || (record.type !== 'assistant' && record.role !== 'assistant')) return null;
|
|
92
|
+
const message = record.message && typeof record.message === 'object' ? record.message : record;
|
|
93
|
+
const usage = message.usage && typeof message.usage === 'object' && !Array.isArray(message.usage) ? message.usage : null;
|
|
94
|
+
if (!usage) return null;
|
|
95
|
+
const read = classifyCache(usage, READ_KEYS);
|
|
96
|
+
const write = classifyCache(usage, WRITE_KEYS);
|
|
97
|
+
const id = typeof message.id === 'string' && SAFE_ID.test(message.id) ? message.id : null;
|
|
98
|
+
return {
|
|
99
|
+
v: USAGE_ROW_VERSION,
|
|
100
|
+
kind: 'usage',
|
|
101
|
+
ts: new Date(now).toISOString(),
|
|
102
|
+
harness,
|
|
103
|
+
provenance: 'HOST_TRANSCRIPT',
|
|
104
|
+
dedupeKey: id ? `id:${id}` : `h:${hash(line ?? JSON.stringify(usage))}`,
|
|
105
|
+
messageId: id,
|
|
106
|
+
model: typeof message.model === 'string' && SAFE_ID.test(message.model) ? message.model : null,
|
|
107
|
+
providerId: safeId(record, message, PROVIDER_KEYS),
|
|
108
|
+
gatewayId: safeId(record, message, GATEWAY_KEYS),
|
|
109
|
+
inputTokens: count(usage.input_tokens ?? usage.input),
|
|
110
|
+
outputTokens: count(usage.output_tokens ?? usage.output),
|
|
111
|
+
cacheReadTokens: read.value,
|
|
112
|
+
cacheWriteTokens: write.value,
|
|
113
|
+
cacheReadStatus: read.status,
|
|
114
|
+
cacheWriteStatus: write.status,
|
|
115
|
+
cacheMetadataStatus: cacheMetadataStatus(read, write),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Consumer helper: hit-rate over rows with KNOWN cache metadata only. A session
|
|
121
|
+
* with any partial coverage record reports coverage:'partial' and withholds the
|
|
122
|
+
* headline hitRate (partialHitRate is explicitly labelled).
|
|
123
|
+
*/
|
|
124
|
+
export function summarizeUsageRows(rows) {
|
|
125
|
+
let partial = false;
|
|
126
|
+
let known = 0; let unknown = 0; let cacheRead = 0; let inputSide = 0;
|
|
127
|
+
for (const row of rows || []) {
|
|
128
|
+
if (row?.kind === 'coverage') { if (row.status === 'partial') partial = true; continue; }
|
|
129
|
+
if (row?.kind !== 'usage') continue;
|
|
130
|
+
if (row.cacheMetadataStatus === 'absent' || row.cacheMetadataStatus === 'invalid') { unknown += 1; continue; }
|
|
131
|
+
known += 1;
|
|
132
|
+
cacheRead += row.cacheReadTokens ?? 0;
|
|
133
|
+
inputSide += (row.inputTokens ?? 0) + (row.cacheReadTokens ?? 0) + (row.cacheWriteTokens ?? 0);
|
|
134
|
+
}
|
|
135
|
+
const rate = inputSide > 0 ? cacheRead / inputSide : null;
|
|
136
|
+
return {
|
|
137
|
+
coverage: partial ? 'partial' : 'complete',
|
|
138
|
+
knownCalls: known,
|
|
139
|
+
unknownCalls: unknown,
|
|
140
|
+
hitRate: partial ? null : rate,
|
|
141
|
+
partialHitRate: partial ? rate : null,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function readConfig(projectRoot) {
|
|
146
|
+
try {
|
|
147
|
+
return JSON.parse(await fs.readFile(path.join(projectRoot, '.ukit', 'storage', 'config.json'), 'utf8'));
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const sessionFile = (value) => crypto.createHash('sha256').update(String(value)).digest('hex').slice(0, 16);
|
|
154
|
+
|
|
155
|
+
async function readLogTailKeys(file) {
|
|
156
|
+
const keys = new Set();
|
|
157
|
+
let handle;
|
|
158
|
+
try {
|
|
159
|
+
handle = await fs.open(file, 'r');
|
|
160
|
+
const { size } = await handle.stat();
|
|
161
|
+
const start = Math.max(0, size - DEDUPE_TAIL_BYTES);
|
|
162
|
+
const buf = Buffer.alloc(size - start);
|
|
163
|
+
await handle.read(buf, 0, buf.length, start);
|
|
164
|
+
let text = buf.toString('utf8');
|
|
165
|
+
if (start > 0) text = text.slice(text.indexOf('\n') + 1);
|
|
166
|
+
for (const line of text.split('\n')) {
|
|
167
|
+
if (!line.includes('"dedupeKey"')) continue;
|
|
168
|
+
try { const k = JSON.parse(line)?.dedupeKey; if (typeof k === 'string') keys.add(k); } catch { /* torn line */ }
|
|
169
|
+
}
|
|
170
|
+
} catch { /* no log yet */ } finally {
|
|
171
|
+
await handle?.close().catch(() => {});
|
|
172
|
+
}
|
|
173
|
+
return keys;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function rotateIfNeeded(file, incoming) {
|
|
177
|
+
try {
|
|
178
|
+
const { size } = await fs.stat(file);
|
|
179
|
+
if (size + incoming > MAX_LOG_BYTES) { await fs.rename(file, `${file}.1`); return true; }
|
|
180
|
+
} catch { /* ENOENT or race: append recreates */ }
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function readCursor(file) {
|
|
185
|
+
try {
|
|
186
|
+
const c = JSON.parse(await fs.readFile(file, 'utf8'));
|
|
187
|
+
if (Number.isInteger(c?.offset) && c.offset >= 0) return c;
|
|
188
|
+
} catch { /* none/corrupt */ }
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function startsAtLineBoundary(handle, offset) {
|
|
193
|
+
if (offset === 0) return true;
|
|
194
|
+
const b = Buffer.alloc(1);
|
|
195
|
+
const { bytesRead } = await handle.read(b, 0, 1, offset - 1);
|
|
196
|
+
return bytesRead === 1 && b[0] === 0x0a;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function captureInner({ projectRoot, payload = {}, harness, now = Date.now() }) {
|
|
200
|
+
const result = { stage: 'off', rows: 0, bytesRead: 0 };
|
|
201
|
+
const config = await readConfig(projectRoot);
|
|
202
|
+
result.stage = resolveUsageCaptureStage(config);
|
|
203
|
+
if (result.stage !== 'shadow') return result;
|
|
204
|
+
const engine = harness || process.env.UKIT_HARNESS || 'claude-code';
|
|
205
|
+
if (engine === 'codex') return { ...result, skipped: 'unsupported-engine' };
|
|
206
|
+
const transcript = payload?.transcript_path;
|
|
207
|
+
if (typeof transcript !== 'string' || !transcript) return { ...result, skipped: 'no-transcript-path' };
|
|
208
|
+
|
|
209
|
+
const dir = path.join(projectRoot, '.ukit', 'storage', 'cache', 'usage-capture');
|
|
210
|
+
const key = sessionFile(payload.session_id || transcript);
|
|
211
|
+
const cursorFile = path.join(dir, `${key}.cursor.json`);
|
|
212
|
+
const logFile = path.join(dir, `${key}.jsonl`);
|
|
213
|
+
await fs.mkdir(dir, { recursive: true });
|
|
214
|
+
|
|
215
|
+
// The cursor is read, advanced and written under ONE lock: two concurrent
|
|
216
|
+
// Stops cannot both consume the same window. A lock timeout leaves the cursor
|
|
217
|
+
// untouched, so the bytes are picked up by the next Stop (delayed, not lost).
|
|
218
|
+
const lock = await withAsyncLock(logFile, { deadlineMs: LOCK_DEADLINE_MS }, async () => {
|
|
219
|
+
const handle = await fs.open(transcript, 'r');
|
|
220
|
+
try {
|
|
221
|
+
const st = await handle.stat();
|
|
222
|
+
const size = st.size;
|
|
223
|
+
const cursor = await readCursor(cursorFile);
|
|
224
|
+
const reasons = [];
|
|
225
|
+
let skippedBytes = 0;
|
|
226
|
+
let offset = 0;
|
|
227
|
+
if (cursor) {
|
|
228
|
+
if ((cursor.ino != null && cursor.ino !== st.ino) || cursor.offset > size) {
|
|
229
|
+
reasons.push('transcript-rotated-or-truncated');
|
|
230
|
+
} else {
|
|
231
|
+
offset = cursor.offset;
|
|
232
|
+
}
|
|
233
|
+
} else {
|
|
234
|
+
reasons.push('initial-window');
|
|
235
|
+
}
|
|
236
|
+
if (size - offset > MAX_READ_BYTES && (!cursor || reasons.length)) {
|
|
237
|
+
// No trustworthy cursor: only the newest window is read; older bytes are a declared gap.
|
|
238
|
+
skippedBytes = size - MAX_READ_BYTES - offset;
|
|
239
|
+
offset = size - MAX_READ_BYTES;
|
|
240
|
+
if (!reasons.length) reasons.push('initial-window');
|
|
241
|
+
} else if (!cursor && size === 0) {
|
|
242
|
+
reasons.length = 0;
|
|
243
|
+
} else if (!reasons.includes('transcript-rotated-or-truncated') && reasons[0] === 'initial-window' && skippedBytes === 0) {
|
|
244
|
+
reasons.length = 0; // whole small transcript read from byte 0: nothing was skipped
|
|
245
|
+
}
|
|
246
|
+
const length = Math.min(size - offset, MAX_READ_BYTES);
|
|
247
|
+
let pendingBytes = size - offset - length;
|
|
248
|
+
const next = { v: 1, offset, ino: st.ino, partial: Boolean(cursor?.partial) };
|
|
249
|
+
let lines = [];
|
|
250
|
+
let consumed = 0;
|
|
251
|
+
if (length > 0) {
|
|
252
|
+
const buf = Buffer.alloc(length);
|
|
253
|
+
const { bytesRead } = await handle.read(buf, 0, length, offset);
|
|
254
|
+
result.bytesRead = bytesRead;
|
|
255
|
+
const text = buf.subarray(0, bytesRead).toString('utf8');
|
|
256
|
+
const lastNl = text.lastIndexOf('\n');
|
|
257
|
+
if (lastNl >= 0) {
|
|
258
|
+
consumed = Buffer.byteLength(text.slice(0, lastNl + 1), 'utf8');
|
|
259
|
+
lines = text.slice(0, lastNl + 1).split('\n');
|
|
260
|
+
if (!(await startsAtLineBoundary(handle, offset))) lines = lines.slice(1); // window began mid-line
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (pendingBytes > 0) reasons.push('backlog');
|
|
264
|
+
next.offset = offset + consumed;
|
|
265
|
+
|
|
266
|
+
let unparsedUsageLines = 0;
|
|
267
|
+
const rows = [];
|
|
268
|
+
const seen = new Set();
|
|
269
|
+
for (const line of lines) {
|
|
270
|
+
if (!line.includes('"usage"')) continue;
|
|
271
|
+
let record;
|
|
272
|
+
try { record = JSON.parse(line); } catch { unparsedUsageLines += 1; continue; }
|
|
273
|
+
const row = buildUsageRow(record, { harness: engine, now, line });
|
|
274
|
+
if (!row) continue;
|
|
275
|
+
if (seen.has(row.dedupeKey)) { // streaming chunks of one message: last chunk wins
|
|
276
|
+
rows[rows.findIndex((r) => r.dedupeKey === row.dedupeKey)] = row;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
seen.add(row.dedupeKey);
|
|
280
|
+
rows.push(row);
|
|
281
|
+
}
|
|
282
|
+
let droppedRows = 0;
|
|
283
|
+
if (rows.length > MAX_ROWS_PER_STOP) { droppedRows = rows.length - MAX_ROWS_PER_STOP; rows.splice(0, droppedRows); reasons.push('row-cap'); }
|
|
284
|
+
if (unparsedUsageLines > 0) reasons.push('unparsed-usage-lines');
|
|
285
|
+
|
|
286
|
+
const known = rows.length ? await readLogTailKeys(logFile) : new Set();
|
|
287
|
+
const fresh = rows.filter((r) => !known.has(r.dedupeKey));
|
|
288
|
+
const records = [...fresh];
|
|
289
|
+
let rotated = false;
|
|
290
|
+
const partialNow = reasons.length > 0;
|
|
291
|
+
if (partialNow) {
|
|
292
|
+
records.push({
|
|
293
|
+
v: USAGE_ROW_VERSION, kind: 'coverage', ts: new Date(now).toISOString(), harness: engine,
|
|
294
|
+
status: 'partial', reasons: [...new Set(reasons)], skippedBytes, pendingBytes, droppedRows, unparsedUsageLines,
|
|
295
|
+
});
|
|
296
|
+
next.partial = true;
|
|
297
|
+
}
|
|
298
|
+
if (records.length) {
|
|
299
|
+
const body = records.map((r) => JSON.stringify(r)).join('\n') + '\n';
|
|
300
|
+
rotated = await rotateIfNeeded(logFile, Buffer.byteLength(body));
|
|
301
|
+
if (rotated) {
|
|
302
|
+
// Older rows beyond the retention window are gone: say so, and let consumers know.
|
|
303
|
+
records.unshift({ v: USAGE_ROW_VERSION, kind: 'coverage', ts: new Date(now).toISOString(), harness: engine, status: 'partial', reasons: ['log-rotated'], skippedBytes: 0, pendingBytes: 0, droppedRows: 0, unparsedUsageLines: 0 });
|
|
304
|
+
next.partial = true;
|
|
305
|
+
}
|
|
306
|
+
await fs.appendFile(logFile, records.map((r) => JSON.stringify(r)).join('\n') + '\n', 'utf8');
|
|
307
|
+
}
|
|
308
|
+
const tmp = `${cursorFile}.${process.pid}.tmp`;
|
|
309
|
+
await fs.writeFile(tmp, JSON.stringify(next));
|
|
310
|
+
await fs.rename(tmp, cursorFile);
|
|
311
|
+
result.rows = fresh.length;
|
|
312
|
+
result.coverage = next.partial ? 'partial' : 'complete';
|
|
313
|
+
if (pendingBytes > 0) result.pendingBytes = pendingBytes;
|
|
314
|
+
} finally {
|
|
315
|
+
await handle.close().catch(() => {});
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
if (lock?.ok === false) return { ...result, skipped: 'lock-timeout' };
|
|
319
|
+
return result;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Capture usage rows appended to the transcript since the last Stop.
|
|
324
|
+
* Never throws and never takes longer than CAPTURE_DEADLINE_MS to return; a
|
|
325
|
+
* deadline hit returns skipped:'deadline' (the next Stop resumes from the cursor).
|
|
326
|
+
*/
|
|
327
|
+
export async function captureUsageShadow(opts = {}) {
|
|
328
|
+
let timer;
|
|
329
|
+
const deadline = new Promise((resolve) => {
|
|
330
|
+
timer = setTimeout(() => resolve({ stage: 'shadow', rows: 0, bytesRead: 0, skipped: 'deadline' }), opts.deadlineMs ?? CAPTURE_DEADLINE_MS);
|
|
331
|
+
timer.unref?.();
|
|
332
|
+
});
|
|
333
|
+
try {
|
|
334
|
+
return await Promise.race([captureInner(opts).catch(() => ({ stage: 'off', rows: 0, bytesRead: 0, skipped: 'error' })), deadline]);
|
|
335
|
+
} finally {
|
|
336
|
+
clearTimeout(timer);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
@@ -751,6 +751,12 @@ function normalizeToolInput(toolName, input) {
|
|
|
751
751
|
const paths = Array.isArray(normalized.paths) ? normalized.paths.filter(Boolean) : [];
|
|
752
752
|
normalized.file_path = normalized.path || paths[0] || undefined;
|
|
753
753
|
}
|
|
754
|
+
// omp's eval tool carries `{language, code}` (no `command`), but block-dangerous and the
|
|
755
|
+
// secret guard only read tool_input.command. Surface the code as the command so the same
|
|
756
|
+
// Bash chain scans it; otherwise an eval payload would reach the gate as an empty command.
|
|
757
|
+
if (toolName === 'Bash' && typeof normalized.command !== 'string' && typeof normalized.code === 'string') {
|
|
758
|
+
normalized.command = normalized.code;
|
|
759
|
+
}
|
|
754
760
|
return normalized;
|
|
755
761
|
}
|
|
756
762
|
|
|
@@ -789,8 +795,39 @@ function sendContext(pi, context, deliverAs, { display = false } = {}) {
|
|
|
789
795
|
pi.sendMessage(hookContextMessage(content, { display }), { deliverAs });
|
|
790
796
|
}
|
|
791
797
|
|
|
792
|
-
|
|
798
|
+
// eval threat model (F2): the Bash chain is a text matcher over a command string. It cannot
|
|
799
|
+
// prove that arbitrary Python/JS is safe (shutil.rmtree, fs.rmSync, pathlib loops, dynamic
|
|
800
|
+
// strings all pass it — verified in a sentinel sandbox). So eval is DENIED by the bridge unless
|
|
801
|
+
// the user opted in with orchestration.allowUnsafeEval=true in .ukit/storage/config.json,
|
|
802
|
+
// independent of .omp/config.yml tools.approval.eval. When opted in, the Bash chain still runs
|
|
803
|
+
// as best-effort defence in depth; it is NOT a safety guarantee for eval code.
|
|
804
|
+
async function readAllowUnsafeEval(projectRoot) {
|
|
805
|
+
try {
|
|
806
|
+
const parsed = JSON.parse(
|
|
807
|
+
await fs.promises.readFile(path.join(projectRoot, '.ukit', 'storage', 'config.json'), 'utf8'),
|
|
808
|
+
);
|
|
809
|
+
return parsed?.orchestration?.allowUnsafeEval === true;
|
|
810
|
+
} catch {
|
|
811
|
+
return false; // missing/corrupt config → fail closed
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
export async function runToolCall(pi, event, { projectRoot, context: extensionContext = {}, allowUnsafeEval } = {}) {
|
|
793
816
|
const rawToolName = event.toolName ?? event.tool;
|
|
817
|
+
if (String(rawToolName ?? '').trim().toLowerCase() === 'eval') {
|
|
818
|
+
const allowed = allowUnsafeEval ?? await readAllowUnsafeEval(projectRoot);
|
|
819
|
+
if (!allowed) {
|
|
820
|
+
return {
|
|
821
|
+
block: true,
|
|
822
|
+
toolName: 'Bash',
|
|
823
|
+
invoked: [],
|
|
824
|
+
context: [],
|
|
825
|
+
reason: 'UKit denies the omp eval tool: arbitrary Python/JS cannot be verified by the shell guard. '
|
|
826
|
+
+ 'Use the bash tool for commands (guarded), or opt in explicitly with orchestration.allowUnsafeEval=true '
|
|
827
|
+
+ 'in .ukit/storage/config.json (unsafe; the guard is best-effort for eval).',
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
}
|
|
794
831
|
const mappedToolName = mapToolName(rawToolName);
|
|
795
832
|
// An unmapped tool that LOOKS like a file mutation must not sail past the guards — but
|
|
796
833
|
// hard-blocking it froze the run whenever omp renamed or added a tool, because the model
|
|
@@ -811,9 +848,16 @@ export async function runToolCall(pi, event, { projectRoot, context: extensionCo
|
|
|
811
848
|
toolUseId: event.toolCallId,
|
|
812
849
|
...metadata,
|
|
813
850
|
});
|
|
814
|
-
//
|
|
815
|
-
//
|
|
816
|
-
|
|
851
|
+
// F4: Edit|Write and Bash (which also carries omp's `eval`) are security-critical and fail
|
|
852
|
+
// CLOSED on a bridge/runner transport failure — an unverifiable shell/eval call is not
|
|
853
|
+
// allowed to proceed. Read|Grep|Glob stay fail-open (documented limit: the secret-read guard
|
|
854
|
+
// is advisory on a runner outage) so diagnosis tools are not bricked. Recovery for a Bash
|
|
855
|
+
// outage is a user action, not a model one: the user exports UKIT_OMP_BASH_FAIL_OPEN=1 (or
|
|
856
|
+
// fixes Node/PATH) and restarts omp. This only affects PreToolUse; the Stop gate is separate,
|
|
857
|
+
// so a blocked Bash call cannot create a Stop-hook loop.
|
|
858
|
+
const group = matcherGroupFor(toolName);
|
|
859
|
+
const bashFailOpenOverride = process.env.UKIT_OMP_BASH_FAIL_OPEN === '1';
|
|
860
|
+
const failClosedOnTransportError = group === 'Edit|Write' || (group === 'Bash' && !bashFailOpenOverride);
|
|
817
861
|
const result = await runScriptChain(pi, scriptsForToolCall(toolName), payload, {
|
|
818
862
|
projectRoot,
|
|
819
863
|
failClosedOnTransportError,
|