@ngockhoale/ukit 3.0.5 → 3.0.7
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 +17 -0
- package/package.json +1 -1
- package/scripts/bench/data-foundation.mjs +562 -0
- package/src/core/observability/adapters/common.js +75 -0
- package/src/core/observability/adapters/contextAdapter.js +55 -0
- package/src/core/observability/adapters/decisionAdapter.js +61 -0
- package/src/core/observability/adapters/routeAdapter.js +135 -0
- package/src/core/observability/analytics/digest.js +186 -0
- package/src/core/observability/analytics/fingerprints.js +126 -0
- package/src/core/observability/analytics/opportunities.js +329 -0
- package/src/core/observability/analytics/rebuild.js +56 -0
- package/src/core/observability/analytics/summary.js +298 -0
- package/src/core/observability/emit/config.js +29 -0
- package/src/core/observability/emit/recorder.js +297 -0
- package/src/core/observability/evaluation/aiPacket.js +230 -0
- package/src/core/observability/evaluation/optimizationKnowledge.js +172 -0
- package/src/core/observability/evaluation/replay.js +143 -0
- package/src/core/observability/evaluation/scorecard.js +445 -0
- package/src/core/observability/privacy/allowlist.js +185 -0
- package/src/core/observability/privacy/redaction.js +113 -0
- package/src/core/observability/privacy/sanitizeForSupport.js +133 -0
- package/src/core/observability/privacy/sanitizeObserved.js +134 -0
- package/src/core/observability/rollout.js +155 -0
- package/src/core/observability/schema/constants.js +66 -0
- package/src/core/observability/schema/registry.js +223 -0
- package/src/core/observability/schema/validate.js +227 -0
- package/src/core/observability/segments/internal.js +241 -0
- package/src/core/observability/segments/readSegments.js +215 -0
- package/src/core/observability/segments/recovery.js +123 -0
- package/src/core/observability/segments/retention.js +381 -0
- package/src/core/observability/support/import.js +402 -0
- package/src/core/observability/support/manifest.js +135 -0
- package/src/core/observability/support/paths.js +94 -0
- package/src/core/observability/support/projector.js +483 -0
- package/src/core/observability/support/renderer.js +130 -0
- package/src/core/observability/support/retention.js +155 -0
- package/src/core/runtimeConfig.js +6 -3
- package/src/decision/client.js +11 -3
- package/template_project/.claude/ukit/index/unic-decision.mjs +6 -2
- package/template_project/.omp/RULES.md +6 -6
- package/template_project/.omp/config.yml +6 -0
- package/template_project/docs/UKIT_INTERNALS.md +9 -0
- package/template_project/instructions/overlays/omp-rules.md +6 -6
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* import.js (TASK-011, SPEC §5 DF-FR11, §8, §10) — untrusted support-bundle
|
|
3
|
+
* import with bounded validation.
|
|
4
|
+
*
|
|
5
|
+
* validateSupportBundle({ path } | { buffer })
|
|
6
|
+
* → Promise<{ ok: true, bundle } | { ok: false, reason, details? }>
|
|
7
|
+
*
|
|
8
|
+
* Input is either a bundle directory (a `UKit Support` folder), a zip
|
|
9
|
+
* file path, or a zip Buffer. Zip parsing uses Node built-ins only —
|
|
10
|
+
* STORED and DEFLATE entries via zlib; no new dependency (PLAN §7).
|
|
11
|
+
*
|
|
12
|
+
* Validation order (fail-fast, first reason wins):
|
|
13
|
+
* input shape → container parse (zip EOCD/CD/local headers, or dir
|
|
14
|
+
* listing) → per-entry name safety (flat namespace, no traversal,
|
|
15
|
+
* no absolute/drive/backslash) → compression method allowlist →
|
|
16
|
+
* entry/total size caps → zip-bomb ratio → CRC32 + declared-size
|
|
17
|
+
* integrity → manifest presence → manifest format/checksum/member
|
|
18
|
+
* verification → file-type allowlist (.md/.json/.jsonl) →
|
|
19
|
+
* records.jsonl line parsing.
|
|
20
|
+
*
|
|
21
|
+
* Defense invariants:
|
|
22
|
+
* - Nothing is ever executed, sourced, or written outside the input
|
|
23
|
+
* location. Entries are data; extraction is in-memory only.
|
|
24
|
+
* - Prose is untrusted: injection-looking strings are imported
|
|
25
|
+
* verbatim and flagged (`untrusted_prose_present`, per-file
|
|
26
|
+
* `injection_signals`), never parsed as instructions.
|
|
27
|
+
* - Format versioning is explicit: only `format: ukit-support/1` is
|
|
28
|
+
* accepted; any other value (or none) is `unsupported_format_version`,
|
|
29
|
+
* never a silent skip. The interchange format is independent of
|
|
30
|
+
* schema_version / metric_version / redaction_version / UKit version.
|
|
31
|
+
* - Project refs stay bundle-local pseudonyms; imported records get a
|
|
32
|
+
* fresh research-local `import_id` plus per-line provenance — raw
|
|
33
|
+
* source identifiers are never re-derived or trusted.
|
|
34
|
+
*
|
|
35
|
+
* Research import store: this module is pure validation — it writes
|
|
36
|
+
* NOTHING. Callers persisting an accepted bundle MUST use a research
|
|
37
|
+
* store separate from the canonical segment root (e.g.
|
|
38
|
+
* `<observability-root>/imports/<import_id>/`); retention/deletion of an
|
|
39
|
+
* import is owned by that store and never touches canonical telemetry —
|
|
40
|
+
* deleting an import drops only the imported copy.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import fs from 'node:fs';
|
|
44
|
+
import path from 'node:path';
|
|
45
|
+
import zlib from 'node:zlib';
|
|
46
|
+
import crypto from 'node:crypto';
|
|
47
|
+
|
|
48
|
+
import { ioReason } from '../segments/internal.js';
|
|
49
|
+
import { verifyManifest, isSafeEntryName, MANIFEST_NAME } from './manifest.js';
|
|
50
|
+
|
|
51
|
+
/** Hard bounds — a support bundle is small by design (projector cap ≈ 2 MB). */
|
|
52
|
+
export const IMPORT_LIMITS = Object.freeze({
|
|
53
|
+
MAX_ENTRIES: 512,
|
|
54
|
+
MAX_ENTRY_BYTES: 16 * 1024 * 1024,
|
|
55
|
+
MAX_TOTAL_BYTES: 32 * 1024 * 1024,
|
|
56
|
+
/** Reject when declared uncompressed total exceeds archive size × this. */
|
|
57
|
+
MAX_RATIO: 100,
|
|
58
|
+
/** …but only above this floor, so tiny archives can't false-positive. */
|
|
59
|
+
RATIO_FLOOR_BYTES: 256 * 1024,
|
|
60
|
+
ALLOWED_EXTENSIONS: new Set(['.md', '.json', '.jsonl']),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const EOCD_SIG = 0x06054b50;
|
|
64
|
+
const CD_SIG = 0x02014b50;
|
|
65
|
+
const LOCAL_SIG = 0x04034b50;
|
|
66
|
+
const EOCD_MIN = 22;
|
|
67
|
+
const EOCD_SEARCH = 22 + 0xffff; // EOCD + max comment length
|
|
68
|
+
|
|
69
|
+
const CRC_TABLE = (() => {
|
|
70
|
+
const table = new Uint32Array(256);
|
|
71
|
+
for (let n = 0; n < 256; n += 1) {
|
|
72
|
+
let c = n;
|
|
73
|
+
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
74
|
+
table[n] = c >>> 0;
|
|
75
|
+
}
|
|
76
|
+
return table;
|
|
77
|
+
})();
|
|
78
|
+
|
|
79
|
+
function crc32(buf) {
|
|
80
|
+
let c = 0xffffffff;
|
|
81
|
+
for (let i = 0; i < buf.length; i += 1) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
|
82
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isPlainObject(value) {
|
|
86
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function fail(reason, details) {
|
|
90
|
+
return details === undefined ? { ok: false, reason } : { ok: false, reason, details };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Zip container parsing (STORED + DEFLATE, central-directory driven)
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
function findEocd(buf) {
|
|
98
|
+
const start = Math.max(0, buf.length - EOCD_SEARCH);
|
|
99
|
+
for (let i = buf.length - EOCD_MIN; i >= start; i -= 1) {
|
|
100
|
+
if (buf.readUInt32LE(i) === EOCD_SIG) return i;
|
|
101
|
+
}
|
|
102
|
+
return -1;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Parse + extract a zip buffer into Map<name, Buffer>. All bounds are
|
|
107
|
+
* checked against DECLARED central-directory sizes before any byte is
|
|
108
|
+
* inflated, so bombs are rejected on metadata alone.
|
|
109
|
+
*/
|
|
110
|
+
function unzip(buf) {
|
|
111
|
+
const entries = new Map();
|
|
112
|
+
if (buf.length < EOCD_MIN) return fail('not_a_zip');
|
|
113
|
+
const eocd = findEocd(buf);
|
|
114
|
+
if (eocd < 0) return fail('not_a_zip');
|
|
115
|
+
|
|
116
|
+
const count = buf.readUInt16LE(eocd + 10);
|
|
117
|
+
const cdSize = buf.readUInt32LE(eocd + 12);
|
|
118
|
+
const cdOffset = buf.readUInt32LE(eocd + 16);
|
|
119
|
+
if (count > IMPORT_LIMITS.MAX_ENTRIES) return fail('too_many_entries');
|
|
120
|
+
if (cdOffset + cdSize > eocd || cdOffset + cdSize > buf.length) {
|
|
121
|
+
return fail('corrupt_central_directory');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const central = [];
|
|
125
|
+
let p = cdOffset;
|
|
126
|
+
for (let i = 0; i < count; i += 1) {
|
|
127
|
+
if (p + 46 > buf.length || buf.readUInt32LE(p) !== CD_SIG) {
|
|
128
|
+
return fail('corrupt_central_directory');
|
|
129
|
+
}
|
|
130
|
+
const method = buf.readUInt16LE(p + 10);
|
|
131
|
+
const crc = buf.readUInt32LE(p + 16);
|
|
132
|
+
const compressedSize = buf.readUInt32LE(p + 20);
|
|
133
|
+
const uncompressedSize = buf.readUInt32LE(p + 24);
|
|
134
|
+
const nameLen = buf.readUInt16LE(p + 28);
|
|
135
|
+
const extraLen = buf.readUInt16LE(p + 30);
|
|
136
|
+
const commentLen = buf.readUInt16LE(p + 32);
|
|
137
|
+
const localOffset = buf.readUInt32LE(p + 42);
|
|
138
|
+
const name = buf.subarray(p + 46, p + 46 + nameLen).toString('utf8');
|
|
139
|
+
central.push({ name, method, crc, compressedSize, uncompressedSize, localOffset });
|
|
140
|
+
p += 46 + nameLen + extraLen + commentLen;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Metadata-level bounds before touching payloads.
|
|
144
|
+
let totalUncompressed = 0;
|
|
145
|
+
for (const e of central) {
|
|
146
|
+
if (!isSafeEntryName(e.name)) {
|
|
147
|
+
return fail('unsafe_entry_name', { name: e.name });
|
|
148
|
+
}
|
|
149
|
+
if (entries.has(e.name)) return fail('duplicate_entry', { name: e.name });
|
|
150
|
+
if (e.method !== 0 && e.method !== 8) {
|
|
151
|
+
return fail('unsupported_compression', { name: e.name, method: e.method });
|
|
152
|
+
}
|
|
153
|
+
if (e.uncompressedSize > IMPORT_LIMITS.MAX_ENTRY_BYTES) {
|
|
154
|
+
return fail('entry_too_large', { name: e.name, bytes: e.uncompressedSize });
|
|
155
|
+
}
|
|
156
|
+
totalUncompressed += e.uncompressedSize;
|
|
157
|
+
if (totalUncompressed > IMPORT_LIMITS.MAX_TOTAL_BYTES) {
|
|
158
|
+
return fail('bundle_too_large', { bytes: totalUncompressed });
|
|
159
|
+
}
|
|
160
|
+
entries.set(e.name, null);
|
|
161
|
+
}
|
|
162
|
+
if (
|
|
163
|
+
totalUncompressed > IMPORT_LIMITS.RATIO_FLOOR_BYTES &&
|
|
164
|
+
totalUncompressed > buf.length * IMPORT_LIMITS.MAX_RATIO
|
|
165
|
+
) {
|
|
166
|
+
return fail('zip_bomb_suspected', {
|
|
167
|
+
uncompressed: totalUncompressed,
|
|
168
|
+
archive_bytes: buf.length,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Extraction: local header → data slice → inflate → CRC + size check.
|
|
173
|
+
for (const e of central) {
|
|
174
|
+
const off = e.localOffset;
|
|
175
|
+
if (off + 30 > buf.length || buf.readUInt32LE(off) !== LOCAL_SIG) {
|
|
176
|
+
return fail('corrupt_local_header', { name: e.name });
|
|
177
|
+
}
|
|
178
|
+
const nameLen = buf.readUInt16LE(off + 26);
|
|
179
|
+
const extraLen = buf.readUInt16LE(off + 28);
|
|
180
|
+
const dataStart = off + 30 + nameLen + extraLen;
|
|
181
|
+
if (dataStart + e.compressedSize > buf.length) {
|
|
182
|
+
return fail('corrupt_local_header', { name: e.name });
|
|
183
|
+
}
|
|
184
|
+
const raw = buf.subarray(dataStart, dataStart + e.compressedSize);
|
|
185
|
+
let data;
|
|
186
|
+
if (e.method === 8) {
|
|
187
|
+
try {
|
|
188
|
+
data = zlib.inflateRawSync(raw, { maxOutputLength: e.uncompressedSize });
|
|
189
|
+
} catch {
|
|
190
|
+
return fail('inflate_failed', { name: e.name });
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
data = Buffer.from(raw);
|
|
194
|
+
}
|
|
195
|
+
if (data.length !== e.uncompressedSize || crc32(data) !== e.crc) {
|
|
196
|
+
return fail('entry_integrity', { name: e.name });
|
|
197
|
+
}
|
|
198
|
+
entries.set(e.name, data);
|
|
199
|
+
}
|
|
200
|
+
return { ok: true, entries };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// Directory input
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
async function readBundleDir(dir) {
|
|
208
|
+
const entries = new Map();
|
|
209
|
+
let listing;
|
|
210
|
+
try {
|
|
211
|
+
listing = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
212
|
+
} catch (err) {
|
|
213
|
+
return fail(ioReason(err));
|
|
214
|
+
}
|
|
215
|
+
let total = 0;
|
|
216
|
+
for (const dent of listing) {
|
|
217
|
+
// Foreign/unsafe names and non-regular files (symlinks, dirs) are
|
|
218
|
+
// ignored — a support dir may hold user files; only manifest-listed
|
|
219
|
+
// members are verified below.
|
|
220
|
+
if (!dent.isFile() || !isSafeEntryName(dent.name)) continue;
|
|
221
|
+
let buf;
|
|
222
|
+
try {
|
|
223
|
+
buf = await fs.promises.readFile(path.join(dir, dent.name));
|
|
224
|
+
} catch (err) {
|
|
225
|
+
return fail(ioReason(err));
|
|
226
|
+
}
|
|
227
|
+
if (buf.length > IMPORT_LIMITS.MAX_ENTRY_BYTES) {
|
|
228
|
+
return fail('entry_too_large', { name: dent.name, bytes: buf.length });
|
|
229
|
+
}
|
|
230
|
+
total += buf.length;
|
|
231
|
+
if (total > IMPORT_LIMITS.MAX_TOTAL_BYTES) {
|
|
232
|
+
return fail('bundle_too_large', { bytes: total });
|
|
233
|
+
}
|
|
234
|
+
entries.set(dent.name, buf);
|
|
235
|
+
}
|
|
236
|
+
return { ok: true, entries };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
// Content checks
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
const INJECTION_PATTERNS = [
|
|
244
|
+
/ignore\s+(all\s+)?(previous|prior)\s+instructions/i,
|
|
245
|
+
/<\s*tool_call\s*>/i,
|
|
246
|
+
/<\/?(system|assistant|user)\s*>/i,
|
|
247
|
+
/```\s*(json|tool|shell|bash)/i,
|
|
248
|
+
/\bSYSTEM\s*:/,
|
|
249
|
+
];
|
|
250
|
+
|
|
251
|
+
function scanInjectionSignals(contents) {
|
|
252
|
+
const signals = [];
|
|
253
|
+
for (const [name, text] of Object.entries(contents)) {
|
|
254
|
+
for (const re of INJECTION_PATTERNS) {
|
|
255
|
+
if (re.test(text)) {
|
|
256
|
+
signals.push({ file: name, pattern: re.source });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return signals;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function parseRecordsJsonl(text, importId) {
|
|
264
|
+
const records = [];
|
|
265
|
+
const bad = [];
|
|
266
|
+
const lines = text.split('\n');
|
|
267
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
268
|
+
const line = lines[i];
|
|
269
|
+
if (line.trim() === '') continue;
|
|
270
|
+
try {
|
|
271
|
+
const record = JSON.parse(line);
|
|
272
|
+
if (!isPlainObject(record)) throw new Error('not an object');
|
|
273
|
+
record.import_id = importId;
|
|
274
|
+
record.provenance = { file: 'records.jsonl', line: i + 1 };
|
|
275
|
+
records.push(record);
|
|
276
|
+
} catch {
|
|
277
|
+
bad.push(i + 1);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (bad.length > 0) {
|
|
281
|
+
return fail('invalid_records_jsonl', { lines: bad });
|
|
282
|
+
}
|
|
283
|
+
return { ok: true, records };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* @param {{ path?: string, buffer?: Buffer }} input
|
|
288
|
+
* @returns {Promise<{ ok: true, bundle: object } | { ok: false, reason: string, details?: object }>}
|
|
289
|
+
*/
|
|
290
|
+
export async function validateSupportBundle(input = {}) {
|
|
291
|
+
const hasPath = typeof input.path === 'string' && input.path.length > 0;
|
|
292
|
+
const hasBuffer = Buffer.isBuffer(input.buffer);
|
|
293
|
+
if (hasPath === hasBuffer) {
|
|
294
|
+
return fail('invalid_input', { need: 'exactly one of { path } or { buffer }' });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
let entries;
|
|
298
|
+
let provenance;
|
|
299
|
+
let isDir = false;
|
|
300
|
+
if (hasBuffer) {
|
|
301
|
+
const parsed = unzip(input.buffer);
|
|
302
|
+
if (!parsed.ok) return parsed;
|
|
303
|
+
entries = parsed.entries;
|
|
304
|
+
provenance = { source: 'buffer' };
|
|
305
|
+
} else {
|
|
306
|
+
let st;
|
|
307
|
+
try {
|
|
308
|
+
st = await fs.promises.lstat(input.path);
|
|
309
|
+
} catch (err) {
|
|
310
|
+
return fail(ioReason(err));
|
|
311
|
+
}
|
|
312
|
+
if (st.isSymbolicLink()) return fail('input_symlink');
|
|
313
|
+
if (st.isDirectory()) {
|
|
314
|
+
isDir = true;
|
|
315
|
+
const read = await readBundleDir(input.path);
|
|
316
|
+
if (!read.ok) return read;
|
|
317
|
+
entries = read.entries;
|
|
318
|
+
} else if (st.isFile()) {
|
|
319
|
+
let buf;
|
|
320
|
+
try {
|
|
321
|
+
buf = await fs.promises.readFile(input.path);
|
|
322
|
+
} catch (err) {
|
|
323
|
+
return fail(ioReason(err));
|
|
324
|
+
}
|
|
325
|
+
const parsed = unzip(buf);
|
|
326
|
+
if (!parsed.ok) return parsed;
|
|
327
|
+
entries = parsed.entries;
|
|
328
|
+
} else {
|
|
329
|
+
return fail('invalid_input');
|
|
330
|
+
}
|
|
331
|
+
provenance = { source: 'path', path: input.path };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// --- manifest: presence → parse → format/checksum/member verification ---
|
|
335
|
+
const manifestBuf = entries.get(MANIFEST_NAME);
|
|
336
|
+
if (!manifestBuf) return fail('missing_manifest');
|
|
337
|
+
let manifest;
|
|
338
|
+
try {
|
|
339
|
+
manifest = JSON.parse(manifestBuf.toString('utf8'));
|
|
340
|
+
} catch {
|
|
341
|
+
return fail('invalid_manifest');
|
|
342
|
+
}
|
|
343
|
+
// strict member↔manifest parity for archives; directories may hold
|
|
344
|
+
// foreign files (projector ownership rule), so verify listed-only.
|
|
345
|
+
const verified = verifyManifest(manifest, entries, { strict: !isDir });
|
|
346
|
+
if (!verified.ok) return verified;
|
|
347
|
+
|
|
348
|
+
// --- file-type allowlist + UTF-8 decode (prose stays inert data) ---
|
|
349
|
+
const contents = {};
|
|
350
|
+
for (const [name, buf] of entries) {
|
|
351
|
+
if (name === MANIFEST_NAME) continue;
|
|
352
|
+
const ext = path.extname(name).toLowerCase();
|
|
353
|
+
if (!IMPORT_LIMITS.ALLOWED_EXTENSIONS.has(ext)) {
|
|
354
|
+
return fail('unsupported_file_type', { name });
|
|
355
|
+
}
|
|
356
|
+
contents[name] = buf.toString('utf8');
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// --- evidence: records.jsonl → research-local IDs + provenance ---
|
|
360
|
+
const importId = `imp-${crypto.randomBytes(8).toString('hex')}`;
|
|
361
|
+
let records = [];
|
|
362
|
+
if (typeof contents['records.jsonl'] === 'string') {
|
|
363
|
+
const parsed = parseRecordsJsonl(contents['records.jsonl'], importId);
|
|
364
|
+
if (!parsed.ok) return parsed;
|
|
365
|
+
records = parsed.records;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const digests = Object.keys(contents)
|
|
369
|
+
.filter((name) => /^digest-.+\.md$/.test(name))
|
|
370
|
+
.sort()
|
|
371
|
+
.map((name) => ({
|
|
372
|
+
name,
|
|
373
|
+
failed: /failed=[1-9]/.test(contents[name]) || /failed=true/i.test(contents[name]),
|
|
374
|
+
}));
|
|
375
|
+
|
|
376
|
+
const proseFiles = Object.keys(contents).filter((name) => name.endsWith('.md'));
|
|
377
|
+
const injectionSignals = scanInjectionSignals(contents);
|
|
378
|
+
const untrustedProse = proseFiles.length > 0;
|
|
379
|
+
|
|
380
|
+
const bundle = {
|
|
381
|
+
import_id: importId,
|
|
382
|
+
format: manifest.format,
|
|
383
|
+
manifest,
|
|
384
|
+
coverage: isPlainObject(manifest.coverage) ? manifest.coverage : {},
|
|
385
|
+
caps: isPlainObject(manifest.caps) ? manifest.caps : {},
|
|
386
|
+
records,
|
|
387
|
+
digests,
|
|
388
|
+
contents,
|
|
389
|
+
checksums_verified: true,
|
|
390
|
+
untrusted_prose_present: untrustedProse,
|
|
391
|
+
provenance,
|
|
392
|
+
report: {
|
|
393
|
+
import_id: importId,
|
|
394
|
+
files: Object.keys(contents).length,
|
|
395
|
+
records: records.length,
|
|
396
|
+
untrusted_prose_present: untrustedProse,
|
|
397
|
+
injection_signals: injectionSignals,
|
|
398
|
+
coverage_gaps: Array.isArray(manifest.coverage?.gaps) ? manifest.coverage.gaps : [],
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
return { ok: true, bundle };
|
|
402
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* manifest.js (TASK-011, SPEC §5 DF-FR11, §8) — support-bundle manifest
|
|
3
|
+
* build/verify: sha256 checksums, byte sizes, coverage + privacy
|
|
4
|
+
* (redaction_version) declarations.
|
|
5
|
+
*
|
|
6
|
+
* buildManifest({ files, generated_at?, coverage?, caps?,
|
|
7
|
+
* project_ref_pseudonym?, ...extra }) → manifest object
|
|
8
|
+
* verifyManifest(manifest, entries, { strict? })
|
|
9
|
+
* → { ok: true, manifest } | { ok: false, reason, details? }
|
|
10
|
+
*
|
|
11
|
+
* `entries` is a Map<string, Buffer> of archive/dir members. With
|
|
12
|
+
* `strict: true` (archive inputs) every member must be listed in the
|
|
13
|
+
* manifest — unlisted entries are rejected as smuggled content. With
|
|
14
|
+
* `strict: false` (directory inputs) foreign files are ignored, matching
|
|
15
|
+
* the projector's ownership rule: a support dir may contain user files.
|
|
16
|
+
*
|
|
17
|
+
* Format versioning (SPEC §12 / test 5): the interchange contract is the
|
|
18
|
+
* `format` marker `ukit-support/<N>` — independent of schema_version,
|
|
19
|
+
* metric_version, redaction_version, and the UKit release version. A
|
|
20
|
+
* reader accepts only the major it implements; anything else is an
|
|
21
|
+
* explicit `unsupported_format_version` rejection, never a silent skip.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import crypto from 'node:crypto';
|
|
25
|
+
|
|
26
|
+
import { SUPPORT_FORMAT } from './projector.js';
|
|
27
|
+
import { REDACTION_VERSION } from '../privacy/allowlist.js';
|
|
28
|
+
import { METRIC_VERSION } from '../analytics/summary.js';
|
|
29
|
+
|
|
30
|
+
export { SUPPORT_FORMAT };
|
|
31
|
+
|
|
32
|
+
export const MANIFEST_NAME = 'manifest.json';
|
|
33
|
+
|
|
34
|
+
/** Flat bundle namespace: no directories, no separators, no dotfiles. */
|
|
35
|
+
export const ENTRY_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
36
|
+
|
|
37
|
+
function isPlainObject(value) {
|
|
38
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sha256Hex(buf) {
|
|
42
|
+
return crypto.createHash('sha256').update(buf).digest('hex');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A member name is safe only when it is a flat, relative, portable file
|
|
47
|
+
* name — no separators, no traversal, no drive letters, no dotfiles.
|
|
48
|
+
*/
|
|
49
|
+
export function isSafeEntryName(name) {
|
|
50
|
+
return typeof name === 'string' && ENTRY_NAME_RE.test(name);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {object} opts
|
|
55
|
+
* @param {Object<string, string|Buffer>} opts.files — file set; the
|
|
56
|
+
* manifest itself is excluded from its own checksum map.
|
|
57
|
+
* @returns {object} manifest
|
|
58
|
+
*/
|
|
59
|
+
export function buildManifest(opts = {}) {
|
|
60
|
+
const files = isPlainObject(opts.files) ? opts.files : {};
|
|
61
|
+
const fileEntries = {};
|
|
62
|
+
for (const [name, content] of Object.entries(files)) {
|
|
63
|
+
if (name === MANIFEST_NAME) continue;
|
|
64
|
+
const buf = Buffer.isBuffer(content) ? content : Buffer.from(String(content), 'utf8');
|
|
65
|
+
fileEntries[name] = { sha256: `sha256:${sha256Hex(buf)}`, bytes: buf.length };
|
|
66
|
+
}
|
|
67
|
+
const { files: _omit, ...rest } = opts;
|
|
68
|
+
return {
|
|
69
|
+
format: SUPPORT_FORMAT,
|
|
70
|
+
generated_at: typeof opts.generated_at === 'string' ? opts.generated_at : new Date().toISOString(),
|
|
71
|
+
redaction_version: REDACTION_VERSION,
|
|
72
|
+
metric_version: METRIC_VERSION,
|
|
73
|
+
...rest,
|
|
74
|
+
files: fileEntries,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Verify a parsed manifest against archive/dir members.
|
|
80
|
+
*
|
|
81
|
+
* @param {object} manifest parsed manifest.json
|
|
82
|
+
* @param {Map<string, Buffer>} entries member name → raw bytes
|
|
83
|
+
* @param {{ strict?: boolean }} [opts] strict = reject unlisted members
|
|
84
|
+
* @returns {{ ok: true, manifest: object } | { ok: false, reason: string, details?: object }}
|
|
85
|
+
*/
|
|
86
|
+
export function verifyManifest(manifest, entries, opts = {}) {
|
|
87
|
+
const strict = opts.strict !== false;
|
|
88
|
+
if (!isPlainObject(manifest)) {
|
|
89
|
+
return { ok: false, reason: 'invalid_manifest' };
|
|
90
|
+
}
|
|
91
|
+
if (manifest.format !== SUPPORT_FORMAT) {
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
reason: 'unsupported_format_version',
|
|
95
|
+
details: { format: manifest.format ?? null, supported: [SUPPORT_FORMAT] },
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (!isPlainObject(manifest.files)) {
|
|
99
|
+
return { ok: false, reason: 'invalid_manifest' };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const listed = Object.keys(manifest.files);
|
|
103
|
+
for (const name of listed) {
|
|
104
|
+
if (!isSafeEntryName(name)) {
|
|
105
|
+
return { ok: false, reason: 'unsafe_entry_name', details: { name } };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const missing = listed.filter((name) => !entries.has(name));
|
|
110
|
+
if (missing.length > 0) {
|
|
111
|
+
return { ok: false, reason: 'missing_files', details: { missing } };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (strict) {
|
|
115
|
+
const unlisted = [...entries.keys()].filter(
|
|
116
|
+
(name) => name !== MANIFEST_NAME && !manifest.files[name],
|
|
117
|
+
);
|
|
118
|
+
if (unlisted.length > 0) {
|
|
119
|
+
return { ok: false, reason: 'unlisted_entries', details: { unlisted } };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const mismatched = [];
|
|
124
|
+
for (const [name, entry] of Object.entries(manifest.files)) {
|
|
125
|
+
const buf = entries.get(name);
|
|
126
|
+
const expected = typeof entry === 'object' && entry !== null ? entry.sha256 : null;
|
|
127
|
+
const actual = `sha256:${sha256Hex(buf)}`;
|
|
128
|
+
if (expected !== actual) mismatched.push(name);
|
|
129
|
+
}
|
|
130
|
+
if (mismatched.length > 0) {
|
|
131
|
+
return { ok: false, reason: 'checksum_mismatch', details: { mismatched } };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { ok: true, manifest };
|
|
135
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* paths.js (TASK-009, SPEC §5 DF-FR10 / §8) — OS-native Documents resolver.
|
|
3
|
+
*
|
|
4
|
+
* resolveSupportDir({ homeDir?, env?, platform? })
|
|
5
|
+
* → { ok: true, dir } | { ok: false, reason }
|
|
6
|
+
*
|
|
7
|
+
* Resolves the user-facing `UKit Support` directory under the OS-native
|
|
8
|
+
* Documents folder:
|
|
9
|
+
* - linux/other: $XDG_DOCUMENTS_DIR, else ~/Documents
|
|
10
|
+
* - darwin: ~/Documents
|
|
11
|
+
* - win32: %USERPROFILE%\Documents, else <homeDir>\Documents
|
|
12
|
+
*
|
|
13
|
+
* The resolver NEVER assumes `~/Documents` exists: a candidate that is
|
|
14
|
+
* absent, not a directory, or not writable produces an explicit typed
|
|
15
|
+
* reason (`documents_not_found` | `documents_not_directory` |
|
|
16
|
+
* `documents_not_writable` | `no_home` | `io_*`). There is no fallback to
|
|
17
|
+
* a leakier location — unresolved stays unresolved and the projector
|
|
18
|
+
* degrades instead of relocating.
|
|
19
|
+
*
|
|
20
|
+
* `dir` is the support directory path itself (Documents/UKit Support);
|
|
21
|
+
* the directory is NOT created here — creation and symlink/foreign-file
|
|
22
|
+
* safety are the projector's job.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import os from 'node:os';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
|
|
29
|
+
import { ioReason } from '../segments/internal.js';
|
|
30
|
+
|
|
31
|
+
export const SUPPORT_DIR_NAME = 'UKit Support';
|
|
32
|
+
|
|
33
|
+
function candidates({ homeDir, env, platform }) {
|
|
34
|
+
const list = [];
|
|
35
|
+
if (platform === 'win32') {
|
|
36
|
+
const profile = env.USERPROFILE || homeDir;
|
|
37
|
+
if (typeof profile === 'string' && profile.length > 0) {
|
|
38
|
+
list.push(path.join(profile, 'Documents'));
|
|
39
|
+
}
|
|
40
|
+
return list;
|
|
41
|
+
}
|
|
42
|
+
if (platform !== 'darwin') {
|
|
43
|
+
const xdg = env.XDG_DOCUMENTS_DIR;
|
|
44
|
+
if (typeof xdg === 'string' && xdg.length > 0) list.push(xdg);
|
|
45
|
+
}
|
|
46
|
+
if (typeof homeDir === 'string' && homeDir.length > 0) {
|
|
47
|
+
list.push(path.join(homeDir, 'Documents'));
|
|
48
|
+
}
|
|
49
|
+
return list;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {{ homeDir?: string|null, env?: object, platform?: string }} [opts]
|
|
54
|
+
* @returns {Promise<{ ok: true, dir: string } | { ok: false, reason: string }>}
|
|
55
|
+
*/
|
|
56
|
+
export async function resolveSupportDir(opts = {}) {
|
|
57
|
+
const env = opts.env && typeof opts.env === 'object' ? opts.env : process.env;
|
|
58
|
+
const platform = typeof opts.platform === 'string' ? opts.platform : process.platform;
|
|
59
|
+
const homeDir = opts.homeDir === undefined ? os.homedir() : opts.homeDir;
|
|
60
|
+
|
|
61
|
+
const list = candidates({ homeDir, env, platform });
|
|
62
|
+
if (list.length === 0) return { ok: false, reason: 'no_home' };
|
|
63
|
+
|
|
64
|
+
let reason = 'documents_not_found';
|
|
65
|
+
for (const documents of list) {
|
|
66
|
+
let stat;
|
|
67
|
+
try {
|
|
68
|
+
// stat (not lstat): a redirected/symlinked Documents folder is a
|
|
69
|
+
// legitimate OS configuration — only the final support path component
|
|
70
|
+
// is held to the no-symlink rule by the projector.
|
|
71
|
+
stat = await fs.promises.stat(documents);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
if (err && err.code === 'ENOENT') continue;
|
|
74
|
+
if (err && (err.code === 'ENOTDIR' || err.code === 'ELOOP')) {
|
|
75
|
+
reason = 'documents_not_directory';
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
reason = ioReason(err);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (!stat.isDirectory()) {
|
|
82
|
+
reason = 'documents_not_directory';
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
await fs.promises.access(documents, fs.constants.W_OK);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
reason = err && err.code === 'EACCES' ? 'documents_not_writable' : ioReason(err);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
return { ok: true, dir: path.join(documents, SUPPORT_DIR_NAME) };
|
|
92
|
+
}
|
|
93
|
+
return { ok: false, reason };
|
|
94
|
+
}
|