full-reverse 0.0.0-stage → 1.0.0

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.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +115 -2
  3. package/THIRD_PARTY_NOTICES.md +63 -0
  4. package/VERIFICATION.md +156 -0
  5. package/bin/full-reverse.mjs +253 -0
  6. package/package.json +30 -4
  7. package/skills/full-reverse/LICENSE +21 -0
  8. package/skills/full-reverse/README.md +116 -0
  9. package/skills/full-reverse/SKILL.md +173 -0
  10. package/skills/full-reverse/THIRD_PARTY_NOTICES.md +63 -0
  11. package/skills/full-reverse/VERIFICATION.md +156 -0
  12. package/skills/full-reverse/agents/openai.yaml +3 -0
  13. package/skills/full-reverse/package-lock.json +75 -0
  14. package/skills/full-reverse/package.json +21 -0
  15. package/skills/full-reverse/references/investigation-contract.md +171 -0
  16. package/skills/full-reverse/references/workflow-example.md +148 -0
  17. package/skills/full-reverse/references/workspace-layout.md +83 -0
  18. package/skills/full-reverse/schemas/claims.schema.json +123 -0
  19. package/skills/full-reverse/schemas/common.schema.json +305 -0
  20. package/skills/full-reverse/schemas/evidence-index.schema.json +135 -0
  21. package/skills/full-reverse/schemas/examples/claims.example.json +355 -0
  22. package/skills/full-reverse/schemas/examples/evidence-index.example.json +254 -0
  23. package/skills/full-reverse/schemas/examples/external-evidence.example.json +33 -0
  24. package/skills/full-reverse/schemas/examples/inventory.example.json +106 -0
  25. package/skills/full-reverse/schemas/examples/investigation.example.json +159 -0
  26. package/skills/full-reverse/schemas/examples/review.example.json +75 -0
  27. package/skills/full-reverse/schemas/external-evidence.schema.json +68 -0
  28. package/skills/full-reverse/schemas/inventory.schema.json +91 -0
  29. package/skills/full-reverse/schemas/investigation.schema.json +142 -0
  30. package/skills/full-reverse/schemas/review.schema.json +78 -0
  31. package/skills/full-reverse/scripts/lib/cli.mjs +254 -0
  32. package/skills/full-reverse/scripts/lib/jsonscan.mjs +285 -0
  33. package/skills/full-reverse/scripts/lib/rea.mjs +496 -0
  34. package/skills/full-reverse/scripts/lib/schemas.mjs +79 -0
  35. package/skills/full-reverse/scripts/lib/scope.mjs +59 -0
  36. package/skills/full-reverse/scripts/lib/selectors.mjs +256 -0
  37. package/skills/full-reverse/scripts/lib/util.mjs +333 -0
  38. package/skills/full-reverse/scripts/lib/validate.mjs +1133 -0
  39. package/skills/full-reverse/scripts/lib/workspace.mjs +861 -0
  40. package/skills/full-reverse/scripts/validate-investigation.mjs +28 -0
  41. package/skills/full-reverse-inspect/SKILL.md +119 -0
  42. package/skills/full-reverse-inspect/agents/openai.yaml +5 -0
  43. package/skills/full-reverse-inspect/references/android.md +84 -0
  44. package/skills/full-reverse-inspect/references/evidence-workflows.md +170 -0
  45. package/skills/full-reverse-inspect/references/javascript-electron.md +146 -0
  46. package/skills/full-reverse-inspect/references/managed.md +59 -0
  47. package/skills/full-reverse-inspect/references/native.md +290 -0
  48. package/skills/full-reverse-inspect/references/packages-and-specialized-artifacts.md +93 -0
  49. package/skills/full-reverse-inspect/references/recorded-evidence.md +118 -0
  50. package/skills/full-reverse-inventory/SKILL.md +134 -0
  51. package/skills/full-reverse-inventory/agents/openai.yaml +5 -0
  52. package/skills/full-reverse-model/SKILL.md +148 -0
  53. package/skills/full-reverse-model/agents/openai.yaml +5 -0
  54. package/skills/full-reverse-model/references/claim-classification.md +85 -0
  55. package/skills/full-reverse-model/references/specification-template.md +199 -0
  56. package/skills/full-reverse-observe/SKILL.md +97 -0
  57. package/skills/full-reverse-observe/agents/openai.yaml +5 -0
  58. package/skills/full-reverse-observe/references/runtime-scope.md +301 -0
  59. package/skills/full-reverse-verify/SKILL.md +111 -0
  60. package/skills/full-reverse-verify/agents/openai.yaml +5 -0
  61. package/skills/full-reverse-verify/references/review-checklist.md +99 -0
@@ -0,0 +1,496 @@
1
+ // Inspection of saved REA producer output: hash, classify, authenticate
2
+ // record identities, extract intrinsic target bindings, resolve RFC 6901 pointers.
3
+ import { createHash } from "node:crypto";
4
+ import fsp from "node:fs/promises";
5
+ import { scanFile } from "./jsonscan.mjs";
6
+ import { canonicalJson, hashFile } from "./util.mjs";
7
+
8
+ export const DEFAULT_MAX_PARSE_BYTES = 128 * 1024 * 1024;
9
+
10
+ const ENVELOPE_REQUIRED = [
11
+ "evidence_id", "subject", "provider", "analysis_profile", "predicate_type", "operation", "parameters",
12
+ "raw_result", "normalized_result", "confidence", "authority", "environment", "limitations", "locations",
13
+ "evidence_links",
14
+ ];
15
+ const ENVELOPE_ALLOWED = new Set([...ENVELOPE_REQUIRED, "relationship_coverage"]);
16
+ const BUNDLE_KEYS = ["artifacts", "providers", "environments", "scenarios", "captures", "unknowns", "records"];
17
+ const EVIDENCE_ID = /^ev_[0-9a-f]{64}$/;
18
+ const SHA256 = /^[0-9a-f]{64}$/;
19
+ export const MAX_INDEXED_RECORDS = 20000;
20
+
21
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
22
+
23
+ /** Does a record envelope have exactly REA's fixed shape (strict, like REA's own parser)? */
24
+ export function hasEnvelopeShape(record) {
25
+ if (!isObject(record)) return false;
26
+ for (const key of ENVELOPE_REQUIRED) if (!(key in record)) return false;
27
+ for (const key of Object.keys(record)) if (!ENVELOPE_ALLOWED.has(key)) return false;
28
+ return typeof record.evidence_id === "string" && EVIDENCE_ID.test(record.evidence_id);
29
+ }
30
+
31
+ /** Recompute REA's semantic evidence ID: ev_ + sha256 of the canonical semantic projection. */
32
+ export function computeEvidenceId(record) {
33
+ const projection = {
34
+ subject:
35
+ record.subject === null
36
+ ? null
37
+ : { digest: record.subject.digest, format: record.subject.format, architecture: record.subject.architecture },
38
+ provider: record.provider,
39
+ analysis_profile: record.analysis_profile,
40
+ predicate_type: record.predicate_type,
41
+ operation: record.operation,
42
+ parameters: record.parameters,
43
+ raw_result: record.raw_result,
44
+ normalized_result: record.normalized_result,
45
+ confidence: record.confidence,
46
+ authority: record.authority,
47
+ environment: record.environment,
48
+ limitations: record.limitations,
49
+ ...(record.relationship_coverage == null ? {} : { relationship_coverage: record.relationship_coverage }),
50
+ locations: record.locations,
51
+ evidence_links: record.evidence_links,
52
+ };
53
+ return `ev_${createHash("sha256").update(canonicalJson(projection)).digest("hex")}`;
54
+ }
55
+
56
+ /**
57
+ * Integer literals beyond 2^53 cannot survive JSON.parse, so an ID mismatch there is not proof of tampering.
58
+ * Scans JSON text outside string literals only (honouring backslash escapes): digits inside strings, such as
59
+ * runs in hex digests, are data, not numeric tokens. A numeric token is the maximal run of number characters;
60
+ * only a plain integer (no fraction/exponent) of 16 or more digits is a risk.
61
+ */
62
+ const UNSAFE_INTEGER_TOKEN = /^-?[0-9]{16,}$/;
63
+ const isNumberChar = (c) => (c >= 48 && c <= 57) || c === 45 || c === 43 || c === 46 || c === 69 || c === 101;
64
+ export function hasPrecisionRisk(text) {
65
+ const length = text.length;
66
+ let i = 0;
67
+ while (i < length) {
68
+ const c = text.charCodeAt(i);
69
+ if (c === 34) {
70
+ i += 1;
71
+ while (i < length) {
72
+ const s = text.charCodeAt(i);
73
+ if (s === 92) i += 2;
74
+ else if (s === 34) break;
75
+ else i += 1;
76
+ }
77
+ i += 1;
78
+ } else if (isNumberChar(c)) {
79
+ const start = i;
80
+ while (i < length && isNumberChar(text.charCodeAt(i))) i += 1;
81
+ if (i - start >= 16 && UNSAFE_INTEGER_TOKEN.test(text.slice(start, i))) return true;
82
+ } else {
83
+ i += 1;
84
+ }
85
+ }
86
+ return false;
87
+ }
88
+
89
+ /**
90
+ * Intrinsic target binding digests of a record, read from saved data only:
91
+ * the REA subject digest, or for process captures the manifest executable digests.
92
+ */
93
+ export function bindingOf(record) {
94
+ if (isObject(record.subject) && isObject(record.subject.digest) && SHA256.test(record.subject.digest.sha256 ?? "")) {
95
+ return { source: "subject", digests: [record.subject.digest.sha256], identityState: null };
96
+ }
97
+ const manifest = record.normalized_result?.manifest;
98
+ if (record.predicate_type === "rea.process-capture" && isObject(manifest)) {
99
+ const digests = [manifest.executable_sha256, manifest.selected_executable_sha256].filter(
100
+ (value) => typeof value === "string" && SHA256.test(value),
101
+ );
102
+ return {
103
+ source: digests.length ? "process-capture-manifest" : "none",
104
+ digests: [...new Set(digests)],
105
+ identityState: isObject(manifest.executable_identity) ? (manifest.executable_identity.state ?? null) : null,
106
+ };
107
+ }
108
+ return { source: "none", digests: [], identityState: null };
109
+ }
110
+
111
+ const summarize = (record, pointer) => ({
112
+ evidence_id: record.evidence_id,
113
+ pointer,
114
+ operation: record.operation,
115
+ predicate_type: record.predicate_type,
116
+ confidence: record.confidence,
117
+ authority: record.authority,
118
+ binding_digests: bindingOf(record).digests,
119
+ parent_evidence_id:
120
+ typeof record.normalized_result?.parent_evidence_id === "string" &&
121
+ EVIDENCE_ID.test(record.normalized_result.parent_evidence_id)
122
+ ? record.normalized_result.parent_evidence_id
123
+ : null,
124
+ });
125
+
126
+ const isView = (summary) => summary.parent_evidence_id !== null || summary.operation === "inspect_analysis_view";
127
+
128
+ const providerOf = (record) =>
129
+ isObject(record.provider)
130
+ ? { id: record.provider.id, name: record.provider.name, version: record.provider.version ?? null }
131
+ : null;
132
+
133
+ function classifyJsonDocument(doc) {
134
+ if (isObject(doc)) {
135
+ if (BUNDLE_KEYS.every((key) => key in doc) && Array.isArray(doc.records)) return "rea-bundle";
136
+ if ("evidence_id" in doc && "normalized_result" in doc) return "rea-record";
137
+ if (typeof doc.error === "string" && typeof doc.code === "string") return "rea-error";
138
+ }
139
+ return "json";
140
+ }
141
+
142
+ function fileKind(contentType, summaries, structureOk, authStatus) {
143
+ if (contentType === "rea-record" && structureOk && authStatus !== "failed") {
144
+ return isView(summaries[0]) ? "derived-view" : "canonical-record";
145
+ }
146
+ if (contentType === "rea-bundle" && structureOk && authStatus !== "failed") return "canonical-bundle";
147
+ return null;
148
+ }
149
+
150
+ /**
151
+ * Inspect one saved file. Streams the SHA-256; parses fully when the file is
152
+ * at most maxParseBytes, otherwise extracts a bounded header with the streaming
153
+ * scanner and reports ID authentication as unverified.
154
+ */
155
+ export async function inspectFile(abs, { maxParseBytes = DEFAULT_MAX_PARSE_BYTES, shallow = false } = {}) {
156
+ const stat = await fsp.stat(abs);
157
+ const result = {
158
+ sha256: null,
159
+ bytes: stat.size,
160
+ content_type: "binary",
161
+ structure: "n/a",
162
+ parse: "none",
163
+ kind_hint: null,
164
+ records: [],
165
+ records_truncated: false,
166
+ operation: null,
167
+ predicate_type: null,
168
+ provider: null,
169
+ producer_version: null,
170
+ id_authentication: { status: "not-applicable", reason: null },
171
+ binding: { source: "none", digests: [], identity_state: null },
172
+ error: null,
173
+ reasons: [],
174
+ jsonl_ok: null,
175
+ };
176
+ if (shallow) {
177
+ const hashed = await hashFile(abs);
178
+ result.sha256 = hashed.sha256;
179
+ result.bytes = hashed.bytes;
180
+ if (hashed.bytes === 0) {
181
+ result.content_type = "empty";
182
+ return result;
183
+ }
184
+ const sniff = Buffer.alloc(Math.min(8192, hashed.bytes));
185
+ const handle = await fsp.open(abs, "r");
186
+ try {
187
+ await handle.read(sniff, 0, sniff.length, 0);
188
+ } finally {
189
+ await handle.close();
190
+ }
191
+ result.content_type = sniff.includes(0) ? "binary" : "text";
192
+ return result;
193
+ }
194
+ if (stat.size === 0) {
195
+ result.sha256 = (await hashFile(abs)).sha256;
196
+ result.content_type = "empty";
197
+ return result;
198
+ }
199
+ if (stat.size <= maxParseBytes) {
200
+ const buffer = await fsp.readFile(abs);
201
+ result.sha256 = createHash("sha256").update(buffer).digest("hex");
202
+ result.bytes = buffer.length;
203
+ const sniff = buffer.subarray(0, 8192);
204
+ if (sniff.includes(0)) return result; // binary
205
+ const text = buffer.toString("utf8");
206
+ result.content_type = "text";
207
+ let doc;
208
+ try {
209
+ doc = JSON.parse(text);
210
+ } catch {
211
+ return finishNonJson(result, text);
212
+ }
213
+ result.parse = "full";
214
+ result.structure = "complete";
215
+ classifyParsed(result, doc, text);
216
+ return result;
217
+ }
218
+ // Large file: stream hash and scan a bounded header.
219
+ result.sha256 = (await hashFile(abs)).sha256;
220
+ const head = Buffer.alloc(8192);
221
+ const handle = await fsp.open(abs, "r");
222
+ try {
223
+ const { bytesRead } = await handle.read(head, 0, 8192, 0);
224
+ if (head.subarray(0, bytesRead).includes(0)) return result;
225
+ } finally {
226
+ await handle.close();
227
+ }
228
+ result.content_type = "text";
229
+ await classifyLarge(result, abs);
230
+ return result;
231
+ }
232
+
233
+ function finishNonJson(result, text) {
234
+ const lines = text.split("\n").filter((line) => line.trim() !== "");
235
+ if (lines.length > 0 && lines.length <= 5_000_000) {
236
+ let ok = true;
237
+ let allOk = true;
238
+ for (const line of lines) {
239
+ try {
240
+ const parsed = JSON.parse(line);
241
+ if (isObject(parsed) && parsed.ok === false) allOk = false;
242
+ } catch {
243
+ ok = false;
244
+ break;
245
+ }
246
+ }
247
+ if (ok) {
248
+ result.content_type = "jsonl";
249
+ result.parse = "full";
250
+ result.structure = "complete";
251
+ result.jsonl_ok = allOk;
252
+ return result;
253
+ }
254
+ }
255
+ if (/^\s*[{[]/.test(text)) {
256
+ result.structure = "incomplete";
257
+ result.reasons.push("json_parse_failed");
258
+ }
259
+ return result;
260
+ }
261
+
262
+ function classifyParsed(result, doc, text) {
263
+ const type = classifyJsonDocument(doc);
264
+ result.content_type = type;
265
+ if (type === "rea-error") {
266
+ result.error = {
267
+ code: doc.code,
268
+ category: typeof doc.category === "string" ? doc.category : null,
269
+ message: typeof doc.message === "string" ? doc.message.slice(0, 500) : null,
270
+ };
271
+ return;
272
+ }
273
+ if (type === "json") return;
274
+ const records = type === "rea-record" ? [[doc, ""]] : doc.records.map((record, index) => [record, `/records/${index}`]);
275
+ let shapeOk = true;
276
+ let authStatus = "verified";
277
+ let authReason = null;
278
+ const summaries = [];
279
+ const risk = hasPrecisionRisk(text);
280
+ for (const [record, pointer] of records) {
281
+ if (!hasEnvelopeShape(record)) {
282
+ shapeOk = false;
283
+ result.reasons.push(`envelope_shape_invalid:${pointer || "/"}`);
284
+ continue;
285
+ }
286
+ if (summaries.length < MAX_INDEXED_RECORDS) summaries.push(summarize(record, pointer));
287
+ else result.records_truncated = true;
288
+ if (computeEvidenceId(record) !== record.evidence_id) {
289
+ if (risk) {
290
+ if (authStatus === "verified") {
291
+ authStatus = "unverified";
292
+ authReason = "evidence_id_mismatch_with_unsafe_integer_literals";
293
+ }
294
+ } else {
295
+ authStatus = "failed";
296
+ authReason = `evidence_id_mismatch:${pointer || "/"}`;
297
+ }
298
+ }
299
+ }
300
+ if (type === "rea-bundle" && new Set(summaries.map((s) => s.evidence_id)).size !== summaries.length && !result.records_truncated) {
301
+ shapeOk = false;
302
+ result.reasons.push("duplicate_record_ids_in_bundle");
303
+ }
304
+ if (!shapeOk) {
305
+ authStatus = "failed";
306
+ authReason ??= result.reasons[0];
307
+ }
308
+ fillFromRecords(result, records.map(([record]) => record), summaries);
309
+ result.id_authentication = { status: authStatus, reason: authReason };
310
+ result.kind_hint = fileKind(type, summaries, shapeOk && summaries.length > 0, authStatus);
311
+ }
312
+
313
+ function fillFromRecords(result, records, summaries) {
314
+ result.records = summaries;
315
+ const first = records.find(isObject);
316
+ if (first) {
317
+ result.operation = typeof first.operation === "string" ? first.operation : null;
318
+ result.predicate_type = typeof first.predicate_type === "string" ? first.predicate_type : null;
319
+ const providers = new Set(records.filter(isObject).map((r) => canonicalJson(providerOf(r))));
320
+ result.provider = providers.size === 1 ? providerOf(first) : null;
321
+ const manifest = first.normalized_result?.manifest;
322
+ if (isObject(manifest) && typeof manifest.rea_version === "string") result.producer_version = manifest.rea_version;
323
+ }
324
+ const digests = new Set();
325
+ let identityState = null;
326
+ let source = "none";
327
+ for (const record of records) {
328
+ if (!isObject(record) || !("predicate_type" in record)) continue;
329
+ const binding = bindingOf(record);
330
+ for (const digest of binding.digests) digests.add(digest);
331
+ if (binding.source !== "none") source = source === "none" || source === binding.source ? binding.source : "subject";
332
+ if (binding.identityState) identityState = binding.identityState;
333
+ }
334
+ result.binding = { source, digests: [...digests], identity_state: identityState };
335
+ }
336
+
337
+ const HEADER_FIELDS = [
338
+ "evidence_id", "subject", "provider", "predicate_type", "operation", "confidence", "authority",
339
+ "normalized_result/parent_evidence_id", "normalized_result/manifest",
340
+ ];
341
+
342
+ async function classifyLarge(result, abs) {
343
+ const patterns = [
344
+ ...HEADER_FIELDS.map((field) => `/${field}`),
345
+ ...HEADER_FIELDS.map((field) => `/records/*/${field}`),
346
+ ];
347
+ const scan = await scanFile(abs, patterns, { maxCaptureBytes: 1 << 20 });
348
+ result.parse = "header-only";
349
+ result.structure = scan.balanced ? "complete" : "incomplete";
350
+ if (!scan.balanced) {
351
+ result.reasons.push("json_structure_incomplete");
352
+ return;
353
+ }
354
+ const keys = new Set(scan.topKeys);
355
+ let type = "json";
356
+ if (scan.rootType === "object") {
357
+ if (BUNDLE_KEYS.every((key) => keys.has(key))) type = "rea-bundle";
358
+ else if (keys.has("evidence_id") && keys.has("normalized_result")) type = "rea-record";
359
+ else if (keys.has("error") && keys.has("code")) type = "rea-error";
360
+ }
361
+ result.content_type = type;
362
+ if (type !== "rea-record" && type !== "rea-bundle") return;
363
+ const get = (prefix, field) => {
364
+ const capture = scan.captures.get(`${prefix}/${field.replace(/\//g, "/")}`);
365
+ if (!capture) return { present: false };
366
+ if (capture.oversized || capture.text === null) return { present: true, oversized: true };
367
+ try {
368
+ return { present: true, value: JSON.parse(capture.text) };
369
+ } catch {
370
+ return { present: true, invalid: true };
371
+ }
372
+ };
373
+ const prefixes = type === "rea-record" ? [""] : Array.from({ length: scan.counts.get("/records") ?? 0 }, (_, i) => `/records/${i}`);
374
+ const records = [];
375
+ let shapeOk = true;
376
+ let bindingUnknown = false;
377
+ for (const prefix of prefixes.slice(0, MAX_INDEXED_RECORDS)) {
378
+ const id = get(prefix, "evidence_id");
379
+ const subject = get(prefix, "subject");
380
+ const parent = get(prefix, "normalized_result/parent_evidence_id");
381
+ const manifest = get(prefix, "normalized_result/manifest");
382
+ const operation = get(prefix, "operation");
383
+ const predicate = get(prefix, "predicate_type");
384
+ if (typeof id.value !== "string" || !EVIDENCE_ID.test(id.value) || !subject.present || typeof operation.value !== "string") {
385
+ shapeOk = false;
386
+ result.reasons.push(`header_incomplete:${prefix || "/"}`);
387
+ continue;
388
+ }
389
+ const partial = {
390
+ evidence_id: id.value,
391
+ subject: subject.value ?? null,
392
+ predicate_type: predicate.value,
393
+ operation: operation.value,
394
+ normalized_result: { parent_evidence_id: parent.value, manifest: manifest.value },
395
+ };
396
+ if (subject.oversized || (manifest.oversized && predicate.value === "rea.process-capture")) bindingUnknown = true;
397
+ const binding = bindingOf(partial);
398
+ const authority = get(prefix, "authority").value;
399
+ const confidence = get(prefix, "confidence").value;
400
+ records.push({ prefix, binding, summary: {
401
+ evidence_id: id.value,
402
+ pointer: prefix,
403
+ operation: operation.value,
404
+ predicate_type: typeof predicate.value === "string" ? predicate.value : "",
405
+ confidence,
406
+ authority: typeof authority === "string" ? authority : "",
407
+ binding_digests: binding.digests,
408
+ parent_evidence_id: typeof parent.value === "string" && EVIDENCE_ID.test(parent.value) ? parent.value : null,
409
+ }, provider: get(prefix, "provider").value, manifest: manifest.value });
410
+ }
411
+ if (prefixes.length > MAX_INDEXED_RECORDS) result.records_truncated = true;
412
+ result.records = records.map((r) => r.summary);
413
+ const first = records[0];
414
+ if (first) {
415
+ result.operation = first.summary.operation;
416
+ result.predicate_type = first.summary.predicate_type;
417
+ result.provider = isObject(first.provider)
418
+ ? { id: first.provider.id, name: first.provider.name, version: first.provider.version ?? null }
419
+ : null;
420
+ if (isObject(first.manifest) && typeof first.manifest.rea_version === "string") result.producer_version = first.manifest.rea_version;
421
+ }
422
+ const digests = new Set(records.flatMap((r) => r.binding.digests));
423
+ const states = records.map((r) => r.binding.identityState).filter(Boolean);
424
+ const sources = new Set(records.map((r) => r.binding.source).filter((s) => s !== "none"));
425
+ result.binding = {
426
+ source: sources.size === 0 ? "none" : sources.size === 1 ? [...sources][0] : "subject",
427
+ digests: [...digests],
428
+ identity_state: states[0] ?? null,
429
+ };
430
+ if (bindingUnknown) result.reasons.push("binding_capture_oversized");
431
+ result.id_authentication = {
432
+ status: shapeOk ? "unverified" : "failed",
433
+ reason: shapeOk ? `file_exceeds_parse_limit` : (result.reasons[0] ?? "header_incomplete"),
434
+ };
435
+ result.kind_hint = fileKind(type, result.records, shapeOk && result.records.length > 0 && !bindingUnknown, result.id_authentication.status);
436
+ }
437
+
438
+ // --- RFC 6901 -----------------------------------------------------------------
439
+
440
+ /** Decode a pointer into segments, or null when it is not a valid RFC 6901 pointer. */
441
+ export function pointerSegments(pointer) {
442
+ if (pointer === "") return [];
443
+ if (typeof pointer !== "string" || !pointer.startsWith("/")) return null;
444
+ const segments = pointer.split("/").slice(1);
445
+ for (const segment of segments) if (/~(?![01])/.test(segment)) return null;
446
+ return segments.map((s) => s.replace(/~1/g, "/").replace(/~0/g, "~"));
447
+ }
448
+
449
+ export const encodePointer = (segments) => segments.map((s) => `/${String(s).replace(/~/g, "~0").replace(/\//g, "~1")}`).join("");
450
+
451
+ /** Resolve a pointer against parsed JSON. Returns { found, value }. */
452
+ export function resolvePointer(doc, pointer) {
453
+ const segments = pointerSegments(pointer);
454
+ if (segments === null) return { found: false, invalid: true };
455
+ let current = doc;
456
+ for (const segment of segments) {
457
+ if (Array.isArray(current)) {
458
+ if (!/^(0|[1-9][0-9]*)$/.test(segment)) return { found: false };
459
+ const index = Number(segment);
460
+ if (index >= current.length) return { found: false };
461
+ current = current[index];
462
+ } else if (isObject(current)) {
463
+ if (!Object.hasOwn(current, segment)) return { found: false };
464
+ current = current[segment];
465
+ } else return { found: false };
466
+ }
467
+ return { found: true, value: current };
468
+ }
469
+
470
+ /** Find pointers whose value equals a JSON scalar/value (bounded DFS). */
471
+ export function locateValues(doc, wanted, { under = "", limit = 20, maxVisited = 5_000_000 } = {}) {
472
+ const start = resolvePointer(doc, under);
473
+ if (!start.found) return { matches: [], visited: 0, truncated: false, startFound: false };
474
+ const target = canonicalJson(wanted);
475
+ const wantedIsContainer = wanted !== null && typeof wanted === "object";
476
+ const matches = [];
477
+ let visited = 0;
478
+ let truncated = false;
479
+ const walk = (value, segments) => {
480
+ if (matches.length >= limit || truncated) return;
481
+ visited += 1;
482
+ if (visited > maxVisited) {
483
+ truncated = true;
484
+ return;
485
+ }
486
+ if (value === null || typeof value !== "object") {
487
+ if (canonicalJson(value) === target) matches.push(under + encodePointer(segments));
488
+ return;
489
+ }
490
+ if (wantedIsContainer && canonicalJson(value) === target) matches.push(under + encodePointer(segments));
491
+ if (Array.isArray(value)) value.forEach((item, index) => walk(item, [...segments, String(index)]));
492
+ else for (const key of Object.keys(value)) walk(value[key], [...segments, key]);
493
+ };
494
+ walk(start.value, []);
495
+ return { matches, visited, truncated: truncated || matches.length >= limit, startFound: true };
496
+ }
@@ -0,0 +1,79 @@
1
+ // Ajv 2020-12 schema loading. Dependencies are installed explicitly by the user
2
+ // (`npm ci --ignore-scripts` in the entry skill); this module never installs anything.
3
+ import fsp from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { CliError, EXIT } from "./util.mjs";
7
+
8
+ export const BUNDLE_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
9
+ export const SCHEMA_DIR = path.join(BUNDLE_DIR, "schemas");
10
+ export const SCHEMA_NAMES = ["investigation", "inventory", "claims", "evidence-index", "external-evidence", "review"];
11
+ export const PINNED_AJV = "8.20.0";
12
+
13
+ const MIN_NODE = [
14
+ { major: 22, minor: 19 },
15
+ { major: 24, minor: 11 },
16
+ { major: 26, minor: 0 },
17
+ ];
18
+
19
+ export function checkNode() {
20
+ const [major, minor] = process.versions.node.split(".").map(Number);
21
+ const ok = MIN_NODE.some((req) => (req.major === 26 ? major >= 26 : major === req.major && minor >= req.minor));
22
+ if (!ok) {
23
+ throw new CliError(`Node ${process.versions.node} is unsupported; need ^22.19.0 || ^24.11.0 || >=26.0.0`, {
24
+ exit: EXIT.PREREQUISITE,
25
+ code: "node_unsupported",
26
+ });
27
+ }
28
+ }
29
+
30
+ let cache = null;
31
+
32
+ /** Load Ajv and compile every schema. Missing dependencies are a prerequisite failure. */
33
+ export async function loadValidators() {
34
+ if (cache) return cache;
35
+ checkNode();
36
+ let Ajv2020;
37
+ let ajvVersion;
38
+ try {
39
+ ({ default: Ajv2020 } = await import("ajv/dist/2020.js"));
40
+ const pkg = JSON.parse(await fsp.readFile(path.join(BUNDLE_DIR, "node_modules", "ajv", "package.json"), "utf8"));
41
+ ajvVersion = pkg.version;
42
+ } catch (error) {
43
+ throw new CliError(
44
+ `Helper dependencies are not installed (${error.code ?? error.message}). Run once: (cd "${BUNDLE_DIR}" && npm ci --ignore-scripts)`,
45
+ { exit: EXIT.PREREQUISITE, code: "dependencies_missing" },
46
+ );
47
+ }
48
+ if (ajvVersion !== PINNED_AJV) {
49
+ throw new CliError(`Installed ajv ${ajvVersion} differs from pinned ${PINNED_AJV}; reinstall with npm ci --ignore-scripts`, {
50
+ exit: EXIT.PREREQUISITE,
51
+ code: "dependency_version_mismatch",
52
+ });
53
+ }
54
+ const ajv = new Ajv2020({ strict: true, strictTypes: false, allErrors: true, allowUnionTypes: true });
55
+ const files = ["common", ...SCHEMA_NAMES];
56
+ const schemas = {};
57
+ for (const name of files) {
58
+ schemas[name] = JSON.parse(await fsp.readFile(path.join(SCHEMA_DIR, `${name}.schema.json`), "utf8"));
59
+ ajv.addSchema(schemas[name]);
60
+ }
61
+ const validators = {};
62
+ for (const name of SCHEMA_NAMES) validators[name] = ajv.getSchema(schemas[name].$id);
63
+ cache = { ajv, ajvVersion, validators, schemas };
64
+ return cache;
65
+ }
66
+
67
+ /** Validate data; returns { valid, errors:[{path,message}] } with bounded error list. */
68
+ export async function validateSchema(name, data, { maxErrors = 25 } = {}) {
69
+ const { validators } = await loadValidators();
70
+ const validate = validators[name];
71
+ if (!validate) throw new CliError(`unknown schema: ${name}`, { exit: EXIT.USAGE, code: "usage" });
72
+ const valid = validate(data);
73
+ if (valid) return { valid: true, errors: [] };
74
+ const errors = (validate.errors ?? []).slice(0, maxErrors).map((error) => ({
75
+ path: error.instancePath || "/",
76
+ message: `${error.message}${error.params && Object.keys(error.params).length ? ` ${JSON.stringify(error.params)}` : ""}`,
77
+ }));
78
+ return { valid: false, errors, truncated: (validate.errors ?? []).length > maxErrors };
79
+ }
@@ -0,0 +1,59 @@
1
+ // Canonical review scope digest. A review attests to the objective, the exclusions, the
2
+ // question wording and the builds it examined. Mutable lifecycle data (question status,
3
+ // reason and claim_ids, steps, authorizations, phase, lifecycle checkpoint) is excluded
4
+ // on purpose: it changes while the investigation runs, and the review itself is bound
5
+ // back into lifecycle.review_sha256, so including it would make the binding circular.
6
+ import fsp from "node:fs/promises";
7
+ import { canonicalJson, resolveInWorkspace, sha256Buffer } from "./util.mjs";
8
+
9
+ export const SCOPE_VERSION = "full-reverse.scope/1";
10
+
11
+ const sorted = (values) => [...values].sort();
12
+
13
+ /** The exact JSON value that is hashed. `investigation` must be schema-valid. */
14
+ export function canonicalScope(investigation) {
15
+ return {
16
+ version: SCOPE_VERSION,
17
+ objective: investigation.objective,
18
+ non_goals: sorted(investigation.non_goals),
19
+ questions: investigation.questions
20
+ .map((q) => ({
21
+ question_id: q.question_id,
22
+ text: q.text,
23
+ feature: q.feature,
24
+ build_ids: sorted(q.build_ids ?? []),
25
+ }))
26
+ .sort((a, b) => (a.question_id < b.question_id ? -1 : a.question_id > b.question_id ? 1 : 0)),
27
+ builds: investigation.builds
28
+ .map((b) => ({
29
+ build_id: b.build_id,
30
+ kind: b.kind,
31
+ name: b.name,
32
+ version_label: b.version_label ?? null,
33
+ identity: b.identity,
34
+ parent_build_id: b.parent_build_id,
35
+ }))
36
+ .sort((a, b) => (a.build_id < b.build_id ? -1 : a.build_id > b.build_id ? 1 : 0)),
37
+ };
38
+ }
39
+
40
+ /** SHA-256 (hex) of the canonical scope of a schema-valid investigation document. */
41
+ export function scopeDigest(investigation) {
42
+ return sha256Buffer(Buffer.from(canonicalJson(canonicalScope(investigation)), "utf8"));
43
+ }
44
+
45
+ /**
46
+ * Scope digest of the workspace's current investigation.json as `{ sha256 }`, or null when
47
+ * the file is missing or is not a document the digest can be computed from. Used by the
48
+ * `digests` command; validation always checks the schema separately.
49
+ */
50
+ export async function workspaceScopeDigest(workspaceAbs) {
51
+ try {
52
+ const resolved = await resolveInWorkspace(workspaceAbs, "investigation.json");
53
+ if (!resolved.exists || !resolved.stat.isFile()) return null;
54
+ const doc = JSON.parse(await fsp.readFile(resolved.abs, "utf8"));
55
+ return { sha256: scopeDigest(doc) };
56
+ } catch {
57
+ return null;
58
+ }
59
+ }