rea-agents 0.3.0 → 0.4.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 (122) hide show
  1. package/README.md +124 -20
  2. package/bridge/hopper_bridge.py +141 -13
  3. package/dist/application/AnalysisProvider.js +12 -1
  4. package/dist/application/ArtifactExtraction.js +166 -0
  5. package/dist/application/ArtifactGraphConstruction.js +257 -0
  6. package/dist/application/ArtifactInventory.js +253 -0
  7. package/dist/application/BinarySession.js +164 -14
  8. package/dist/application/CompositeProvider.js +73 -0
  9. package/dist/application/DirectAnalysis.js +27 -3
  10. package/dist/application/Doctor.js +35 -6
  11. package/dist/application/EnhancedTools.js +8 -7
  12. package/dist/application/EvidenceBundleCommands.js +29 -0
  13. package/dist/application/EvidenceBundleFiles.js +127 -0
  14. package/dist/application/EvidenceLedger.js +225 -18
  15. package/dist/application/FilesystemSnapshot.js +124 -0
  16. package/dist/application/LinuxHopper.js +186 -0
  17. package/dist/application/LoopbackReplay.js +195 -37
  18. package/dist/application/ProcessHarness.js +200 -191
  19. package/dist/application/ProcessNormalization.js +44 -0
  20. package/dist/application/ProcessOwnership.js +84 -0
  21. package/dist/application/ProcessSampling.js +284 -0
  22. package/dist/application/RealHopperAssertions.js +105 -0
  23. package/dist/application/ReferenceSourceImport.js +182 -0
  24. package/dist/application/ReferenceSourceImportEntries.js +122 -0
  25. package/dist/application/ReferenceSourceImportPolicy.js +73 -0
  26. package/dist/application/ReferenceSourceImportTypes.js +18 -0
  27. package/dist/application/ReferenceSourceVcsAdapter.js +34 -0
  28. package/dist/application/Setup.js +192 -41
  29. package/dist/application/Uninstall.js +130 -0
  30. package/dist/application/runtime.js +8 -1
  31. package/dist/artifacts/ArtifactPaths.js +51 -0
  32. package/dist/artifacts/ArtifactProvider.js +130 -0
  33. package/dist/artifacts/ArtifactReader.js +9 -0
  34. package/dist/artifacts/AsarArtifactReader.js +62 -0
  35. package/dist/artifacts/DirectoryArtifactReader.js +107 -0
  36. package/dist/artifacts/MachOSliceArtifactReader.js +66 -0
  37. package/dist/artifacts/SafeOutputTree.js +199 -0
  38. package/dist/artifacts/StreamBytes.js +10 -0
  39. package/dist/artifacts/ZipArtifactReader.js +109 -0
  40. package/dist/cli.js +149 -2
  41. package/dist/config.js +35 -1
  42. package/dist/contracts/artifactComparisonExample.js +95 -0
  43. package/dist/contracts/artifactToolContracts.js +84 -0
  44. package/dist/contracts/enhancedInputs.js +4 -0
  45. package/dist/contracts/functionComparisonExample.js +57 -0
  46. package/dist/contracts/investigationExamples.js +118 -0
  47. package/dist/contracts/nativeToolContracts.js +52 -0
  48. package/dist/contracts/processCaptureExample.js +25 -0
  49. package/dist/contracts/toolContractExamples.js +69 -0
  50. package/dist/contracts/toolContracts.js +99 -36
  51. package/dist/contracts/toolOutputSchemas.js +154 -82
  52. package/dist/contracts/unknownContractExamples.js +33 -0
  53. package/dist/domain/artifactComparison.js +273 -0
  54. package/dist/domain/artifactGraph.js +194 -0
  55. package/dist/domain/artifactInventoryEvidence.js +150 -0
  56. package/dist/domain/binaryTarget.js +55 -0
  57. package/dist/domain/bundleComparison.js +266 -0
  58. package/dist/domain/callPath.js +346 -0
  59. package/dist/domain/changedBehavior.js +294 -0
  60. package/dist/domain/errors.js +192 -4
  61. package/dist/domain/evidence.js +41 -10
  62. package/dist/domain/evidenceBundle.js +187 -6
  63. package/dist/domain/functionComparison.js +201 -0
  64. package/dist/domain/functionComparisonNormalization.js +112 -0
  65. package/dist/domain/functionComparisonResults.js +54 -0
  66. package/dist/domain/functionComparisonSchemas.js +82 -0
  67. package/dist/domain/functionDossierEvidence.js +171 -0
  68. package/dist/domain/hopperValues.js +14 -7
  69. package/dist/domain/nativeInspection.js +142 -0
  70. package/dist/domain/processCapture.js +152 -54
  71. package/dist/domain/processComparison.js +106 -0
  72. package/dist/domain/reconstructionUnknowns.js +90 -0
  73. package/dist/domain/reconstructionVerification.js +285 -0
  74. package/dist/domain/reconstructionVerificationSchemas.js +126 -0
  75. package/dist/domain/referenceSourceClassification.js +496 -0
  76. package/dist/domain/referenceSourceGraph.js +376 -0
  77. package/dist/domain/referenceSourceImportParsing.js +235 -0
  78. package/dist/domain/referenceSourcePolicy.js +1 -0
  79. package/dist/domain/residualUnknown.js +239 -0
  80. package/dist/domain/staticRuntimeCorrelation.js +375 -0
  81. package/dist/hopper/BridgeLauncher.js +40 -3
  82. package/dist/hopper/HopperClient.js +14 -5
  83. package/dist/hopper/HopperProvider.js +57 -22
  84. package/dist/identity.js +1 -0
  85. package/dist/main.js +5 -1
  86. package/dist/native/CommandRunner.js +156 -0
  87. package/dist/native/NativeMacOSProvider.js +306 -0
  88. package/dist/native/NativeMachoInspection.js +135 -0
  89. package/dist/native/parsers/codesign.js +55 -0
  90. package/dist/native/parsers/demangle.js +26 -0
  91. package/dist/native/parsers/dyldInfo.js +25 -0
  92. package/dist/native/parsers/lipo.js +67 -0
  93. package/dist/native/parsers/otool.js +193 -0
  94. package/dist/native/parsers/plist.js +23 -0
  95. package/dist/reference/ReferenceSourceReader.js +73 -0
  96. package/dist/reference/ReferenceSourceReaderEntries.js +206 -0
  97. package/dist/reference/ReferenceSourceReaderErrors.js +19 -0
  98. package/dist/reference/ReferenceSourceReaderFile.js +119 -0
  99. package/dist/reference/ReferenceSourceReaderPaths.js +23 -0
  100. package/dist/reference/ReferenceSourceReaderTypes.js +2 -0
  101. package/dist/reference/ReferenceSourceReaderValidate.js +71 -0
  102. package/dist/server/createServer.js +31 -5
  103. package/dist/server/recordDerivedEvidence.js +10 -0
  104. package/dist/server/registerArtifactComparisonTool.js +62 -0
  105. package/dist/server/registerArtifactTools.js +6 -0
  106. package/dist/server/registerBundleComparisonTool.js +47 -0
  107. package/dist/server/registerEnhancedTools.js +64 -12
  108. package/dist/server/registerEvidenceTools.js +36 -0
  109. package/dist/server/registerFunctionComparisonTool.js +68 -0
  110. package/dist/server/registerInvestigationTools.js +224 -0
  111. package/dist/server/registerNativeTools.js +6 -0
  112. package/dist/server/registerOfficialTools.js +47 -14
  113. package/dist/server/registerProcessComparisonTool.js +106 -0
  114. package/dist/server/registerSessionTools.js +179 -70
  115. package/dist/server/sessionEvidence.js +28 -0
  116. package/dist/server/sessionToolPolicies.js +64 -0
  117. package/dist/server/toolRegistrationOptions.js +7 -0
  118. package/dist/server/toolResult.js +8 -5
  119. package/install.sh +198 -0
  120. package/package.json +18 -1
  121. package/scripts/rea.mjs +5 -1
  122. package/skills/rea-analysis/SKILL.md +77 -2
@@ -0,0 +1,127 @@
1
+ import { lstat, readFile, realpath } from "node:fs/promises";
2
+ import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
3
+ import writeFileAtomic from "write-file-atomic";
4
+ import { parseEvidenceBundle, serializeEvidenceBundle, } from "../domain/evidenceBundle.js";
5
+ import { EvidenceFileError, EvidenceIntegrityError } from "../domain/errors.js";
6
+ import { err, ok } from "../domain/result.js";
7
+ /** Read and validate a bounded evidence bundle inside an approved root. */
8
+ export const readEvidenceBundle = async (path, policy) => {
9
+ if (policy.roots.length === 0)
10
+ return err(new EvidenceFileError("read", "disabled"));
11
+ try {
12
+ const canonicalPath = await realpath(resolve(path));
13
+ if (!(await isApproved(canonicalPath, policy.roots)))
14
+ return err(new EvidenceFileError("read", "outside-root"));
15
+ const stats = await lstat(canonicalPath);
16
+ if (!stats.isFile())
17
+ return err(new EvidenceFileError("read", "not-file"));
18
+ if (stats.size > policy.maxBytes)
19
+ return err(new EvidenceFileError("read", "too-large"));
20
+ const encoded = await readFile(canonicalPath);
21
+ if (encoded.byteLength > policy.maxBytes)
22
+ return err(new EvidenceFileError("read", "too-large"));
23
+ let decoded;
24
+ try {
25
+ decoded = JSON.parse(encoded.toString("utf8"));
26
+ }
27
+ catch (cause) {
28
+ return err(new EvidenceFileError("read", "invalid-json", { cause }));
29
+ }
30
+ const limits = validateJsonLimits(decoded, policy);
31
+ if (!limits.ok)
32
+ return limits;
33
+ try {
34
+ return ok(parseEvidenceBundle(decoded));
35
+ }
36
+ catch (cause) {
37
+ return err(new EvidenceIntegrityError("Evidence bundle validation failed", {
38
+ cause,
39
+ }));
40
+ }
41
+ }
42
+ catch (cause) {
43
+ return err(new EvidenceFileError("read", "io", { cause }));
44
+ }
45
+ };
46
+ /** Atomically write deterministic evidence JSON inside an approved root. */
47
+ export const writeEvidenceBundle = async (bundle, path, overwrite, policy) => {
48
+ if (policy.roots.length === 0)
49
+ return err(new EvidenceFileError("write", "disabled"));
50
+ let encoded;
51
+ try {
52
+ encoded = serializeEvidenceBundle(bundle);
53
+ }
54
+ catch (cause) {
55
+ return err(new EvidenceIntegrityError("Evidence bundle validation failed", {
56
+ cause,
57
+ }));
58
+ }
59
+ const bytes = Buffer.byteLength(encoded, "utf8");
60
+ if (bytes > policy.maxBytes)
61
+ return err(new EvidenceFileError("write", "too-large"));
62
+ try {
63
+ const canonicalParent = await realpath(dirname(resolve(path)));
64
+ const destination = resolve(canonicalParent, basename(resolve(path)));
65
+ if (!(await isApproved(destination, policy.roots)))
66
+ return err(new EvidenceFileError("write", "outside-root"));
67
+ const existing = await lstat(destination).catch((cause) => {
68
+ if (isFileNotFound(cause))
69
+ return undefined;
70
+ throw cause;
71
+ });
72
+ if (existing !== undefined) {
73
+ if (!overwrite)
74
+ return err(new EvidenceFileError("write", "exists"));
75
+ if (!existing.isFile() || existing.isSymbolicLink())
76
+ return err(new EvidenceFileError("write", "not-file"));
77
+ }
78
+ await writeFileAtomic(destination, encoded, {
79
+ encoding: "utf8",
80
+ mode: 0o600,
81
+ fsync: true,
82
+ });
83
+ return ok({ path: destination, bytes });
84
+ }
85
+ catch (cause) {
86
+ return err(new EvidenceFileError("write", "io", { cause }));
87
+ }
88
+ };
89
+ const isApproved = async (candidate, roots) => {
90
+ for (const configuredRoot of roots) {
91
+ const root = await realpath(resolve(configuredRoot));
92
+ const relation = relative(root, candidate);
93
+ if (relation === "" ||
94
+ (!relation.startsWith("..") && !isAbsolute(relation)))
95
+ return true;
96
+ }
97
+ return false;
98
+ };
99
+ const validateJsonLimits = (root, policy) => {
100
+ const pending = [
101
+ { value: root, depth: 0 },
102
+ ];
103
+ let nodes = 0;
104
+ while (pending.length > 0) {
105
+ const current = pending.pop();
106
+ if (current === undefined)
107
+ break;
108
+ nodes += 1;
109
+ if (nodes > policy.maxNodes || current.depth > policy.maxDepth)
110
+ return err(new EvidenceFileError("read", "too-large"));
111
+ if (typeof current.value === "string" &&
112
+ current.value.length > policy.maxStringLength)
113
+ return err(new EvidenceFileError("read", "too-large"));
114
+ if (typeof current.value !== "object" || current.value === null)
115
+ continue;
116
+ for (const [key, value] of Object.entries(current.value)) {
117
+ if (key.length > policy.maxStringLength)
118
+ return err(new EvidenceFileError("read", "too-large"));
119
+ pending.push({ value, depth: current.depth + 1 });
120
+ }
121
+ }
122
+ return ok(null);
123
+ };
124
+ const isFileNotFound = (cause) => typeof cause === "object" &&
125
+ cause !== null &&
126
+ "code" in cause &&
127
+ cause.code === "ENOENT";
@@ -1,56 +1,263 @@
1
- import { createEvidenceBundle, evidenceBundleSchema, } from "../domain/evidenceBundle.js";
1
+ import { createEvidenceBundle, parseEvidenceBundle, } from "../domain/evidenceBundle.js";
2
+ import { EvidenceIntegrityError, EvidenceLimitError, UnknownRegistryError, } from "../domain/errors.js";
2
3
  import { parseEvidence } from "../domain/evidence.js";
4
+ import { createResidualUnknown, updateResidualUnknown, } from "../domain/residualUnknown.js";
5
+ import { err, ok } from "../domain/result.js";
3
6
  /** Bounded, session-owned set of immutable evidence records. */
4
7
  export class EvidenceLedger {
5
8
  limits;
6
9
  #records = new Map();
10
+ #unknownRevisions = new Map();
11
+ #unknownHeads = new Map();
12
+ #bytes = 0;
7
13
  constructor(limits) {
8
14
  this.limits = limits;
9
15
  if (!Number.isSafeInteger(limits.maxRecords) || limits.maxRecords < 1)
10
16
  throw new RangeError("maxRecords must be a positive safe integer");
17
+ if (!Number.isSafeInteger(limits.maxBytes) || limits.maxBytes < 1)
18
+ throw new RangeError("maxBytes must be a positive safe integer");
11
19
  }
12
20
  /** Record evidence idempotently; conflicting content is rejected. */
13
21
  record(input) {
14
- const evidence = parseEvidence(input);
22
+ let evidence;
23
+ try {
24
+ evidence = parseEvidence(input);
25
+ }
26
+ catch (cause) {
27
+ return err(new EvidenceIntegrityError("Evidence record validation failed", {
28
+ cause,
29
+ }));
30
+ }
15
31
  const existing = this.#records.get(evidence.evidence_id);
16
32
  if (existing !== undefined) {
17
- if (!recordsAgree(existing, evidence))
18
- throw new Error(`Conflicting evidence record: ${evidence.evidence_id}`);
19
- return "duplicate";
33
+ return recordsAgree(existing, evidence)
34
+ ? ok("duplicate")
35
+ : err(new EvidenceIntegrityError("Conflicting evidence record"));
20
36
  }
21
37
  if (this.#records.size >= this.limits.maxRecords)
22
- throw new RangeError("Evidence ledger record limit exceeded");
38
+ return err(new EvidenceLimitError("records", this.limits.maxRecords));
39
+ const bytes = serializedBytes(evidence);
40
+ if (this.#bytes + bytes > this.limits.maxBytes)
41
+ return err(new EvidenceLimitError("bytes", this.limits.maxBytes));
23
42
  this.#records.set(evidence.evidence_id, evidence);
24
- return "added";
43
+ this.#bytes += bytes;
44
+ return ok("added");
25
45
  }
26
46
  /** Validate an entire bundle before atomically merging its records. */
27
47
  import(input) {
28
- const bundle = evidenceBundleSchema.parse(input);
48
+ let bundle;
49
+ try {
50
+ bundle = parseEvidenceBundle(input);
51
+ }
52
+ catch (cause) {
53
+ return err(new EvidenceIntegrityError("Evidence bundle validation failed", {
54
+ cause,
55
+ }));
56
+ }
29
57
  const pending = new Map(this.#records);
30
- for (const unparsed of bundle.records) {
31
- const evidence = parseEvidence(unparsed);
58
+ for (const evidence of bundle.records) {
32
59
  const existing = pending.get(evidence.evidence_id);
33
60
  if (existing !== undefined && !recordsAgree(existing, evidence))
34
- throw new Error(`Conflicting evidence record: ${evidence.evidence_id}`);
61
+ return err(new EvidenceIntegrityError("Conflicting evidence record"));
35
62
  pending.set(evidence.evidence_id, evidence);
36
63
  }
37
- if (pending.size > this.limits.maxRecords)
38
- throw new RangeError("Evidence ledger record limit exceeded");
64
+ const pendingUnknowns = new Map(this.#unknownRevisions);
65
+ for (const unknown of bundle.unknowns) {
66
+ const key = unknownRevisionKey(unknown);
67
+ const existing = pendingUnknowns.get(key);
68
+ if (existing !== undefined &&
69
+ existing.revision_digest !== unknown.revision_digest)
70
+ return err(new EvidenceIntegrityError("Conflicting unknown revision"));
71
+ pendingUnknowns.set(key, unknown);
72
+ }
73
+ const checked = this.#validateCandidate(pending, pendingUnknowns);
74
+ if (!checked.ok) {
75
+ if (checked.error instanceof UnknownRegistryError)
76
+ return err(new EvidenceIntegrityError("Evidence bundle unknown registry failed", {
77
+ cause: checked.error,
78
+ }));
79
+ return err(checked.error);
80
+ }
39
81
  const added = pending.size - this.#records.size;
40
- this.#records.clear();
41
- for (const [id, evidence] of pending)
42
- this.#records.set(id, evidence);
43
- return added;
82
+ this.#commit(pending, pendingUnknowns, checked.value);
83
+ return ok(added);
44
84
  }
45
85
  /** Export records in deterministic, semantically irrelevant ID order. */
46
86
  export() {
47
- return createEvidenceBundle([...this.#records.values()]);
87
+ return createEvidenceBundle([...this.#records.values()], [...this.#unknownRevisions.values()]);
88
+ }
89
+ /** Check whether one immutable Evidence ID is present in this ledger. */
90
+ has(evidenceId) {
91
+ return this.#records.has(evidenceId);
92
+ }
93
+ /** Return a detached immutable Evidence value by semantic ID. */
94
+ get(evidenceId) {
95
+ const evidence = this.#records.get(evidenceId);
96
+ return evidence === undefined ? undefined : structuredClone(evidence);
97
+ }
98
+ /** Create one approved unknown and atomically append its mutation evidence. */
99
+ recordUnknown(input, mutationEvidence) {
100
+ let parsedMutation;
101
+ try {
102
+ parsedMutation = parseEvidence(mutationEvidence);
103
+ }
104
+ catch (cause) {
105
+ return err(new EvidenceIntegrityError("Unknown mutation evidence is invalid", {
106
+ cause,
107
+ }));
108
+ }
109
+ let unknown;
110
+ try {
111
+ unknown = createResidualUnknown(input, parsedMutation.evidence_id, parsedMutation.subject?.digest.sha256 ?? null);
112
+ }
113
+ catch (cause) {
114
+ return err(new UnknownRegistryError("invalid-transition", { cause }));
115
+ }
116
+ if (this.#unknownHeads.has(unknown.unknown_id))
117
+ return err(new UnknownRegistryError("already-exists"));
118
+ return this.#appendUnknown(unknown, parsedMutation);
119
+ }
120
+ /** Atomically record derived Evidence and an approved residual unknown. */
121
+ recordWithUnknown(evidenceInput, input, mutationInput) {
122
+ let evidence;
123
+ let mutation;
124
+ try {
125
+ evidence = parseEvidence(evidenceInput);
126
+ mutation = parseEvidence(mutationInput);
127
+ }
128
+ catch (cause) {
129
+ return err(new EvidenceIntegrityError("Atomic Evidence validation failed", {
130
+ cause,
131
+ }));
132
+ }
133
+ const existingEvidence = this.#records.get(evidence.evidence_id);
134
+ if (existingEvidence !== undefined &&
135
+ !recordsAgree(existingEvidence, evidence))
136
+ return err(new EvidenceIntegrityError("Conflicting evidence record"));
137
+ let unknown;
138
+ try {
139
+ unknown = createResidualUnknown(input, mutation.evidence_id, mutation.subject?.digest.sha256 ?? null);
140
+ }
141
+ catch (cause) {
142
+ return err(new UnknownRegistryError("invalid-transition", { cause }));
143
+ }
144
+ const pendingRecords = new Map(this.#records);
145
+ pendingRecords.set(evidence.evidence_id, evidence);
146
+ const existingUnknown = this.#unknownHeads.get(unknown.unknown_id);
147
+ if (existingUnknown !== undefined) {
148
+ if (existingUnknown.revision !== 1 ||
149
+ existingUnknown.revision_digest !== unknown.revision_digest)
150
+ return err(new UnknownRegistryError("already-exists"));
151
+ const checked = this.#validateCandidate(pendingRecords, this.#unknownRevisions);
152
+ if (!checked.ok)
153
+ return checked;
154
+ this.#commit(pendingRecords, this.#unknownRevisions, checked.value);
155
+ return ok(null);
156
+ }
157
+ pendingRecords.set(mutation.evidence_id, mutation);
158
+ const pendingUnknowns = new Map(this.#unknownRevisions);
159
+ pendingUnknowns.set(unknownRevisionKey(unknown), unknown);
160
+ const checked = this.#validateCandidate(pendingRecords, pendingUnknowns);
161
+ if (!checked.ok)
162
+ return checked;
163
+ this.#commit(pendingRecords, pendingUnknowns, checked.value);
164
+ return ok(unknown);
165
+ }
166
+ /** Apply one approved full-state update using optimistic revision matching. */
167
+ updateUnknown(input, mutationEvidence) {
168
+ const current = this.#unknownHeads.get(input.unknown_id);
169
+ if (current === undefined)
170
+ return err(new UnknownRegistryError("not-found"));
171
+ if (current.revision !== input.expected_revision)
172
+ return err(new UnknownRegistryError("revision-conflict"));
173
+ let unknown;
174
+ try {
175
+ unknown = updateResidualUnknown(current, input, mutationEvidence.evidence_id);
176
+ }
177
+ catch (cause) {
178
+ return err(new UnknownRegistryError("invalid-transition", { cause }));
179
+ }
180
+ return this.#appendUnknown(unknown, mutationEvidence);
181
+ }
182
+ /** Query current heads in stable ID order. */
183
+ listUnknowns(filters = {}) {
184
+ return [...this.#unknownHeads.values()]
185
+ .filter((unknown) => (filters.status === undefined || unknown.status === filters.status) &&
186
+ (filters.severity === undefined ||
187
+ unknown.severity === filters.severity) &&
188
+ (filters.domain === undefined || unknown.domain === filters.domain))
189
+ .sort((left, right) => left.unknown_id.localeCompare(right.unknown_id));
190
+ }
191
+ /** Return current resolution validity; imported invalid states are rejected. */
192
+ verifyUnknownResolution(unknownId) {
193
+ const unknown = this.#unknownHeads.get(unknownId);
194
+ if (unknown === undefined)
195
+ return err(new UnknownRegistryError("not-found"));
196
+ return ok({
197
+ valid: unknown.status === "resolved",
198
+ truthVerified: unknown.resolution?.disposition === "verified",
199
+ unknown,
200
+ });
48
201
  }
49
202
  /** Clear records when the owning session closes. */
50
203
  clear() {
51
204
  this.#records.clear();
205
+ this.#unknownRevisions.clear();
206
+ this.#unknownHeads.clear();
207
+ this.#bytes = 0;
208
+ }
209
+ #appendUnknown(unknown, mutationEvidenceInput) {
210
+ let mutationEvidence;
211
+ try {
212
+ mutationEvidence = parseEvidence(mutationEvidenceInput);
213
+ }
214
+ catch (cause) {
215
+ return err(new EvidenceIntegrityError("Unknown mutation evidence is invalid", {
216
+ cause,
217
+ }));
218
+ }
219
+ const pendingRecords = new Map(this.#records);
220
+ pendingRecords.set(mutationEvidence.evidence_id, mutationEvidence);
221
+ const pendingUnknowns = new Map(this.#unknownRevisions);
222
+ pendingUnknowns.set(unknownRevisionKey(unknown), unknown);
223
+ const checked = this.#validateCandidate(pendingRecords, pendingUnknowns);
224
+ if (!checked.ok)
225
+ return checked;
226
+ this.#commit(pendingRecords, pendingUnknowns, checked.value);
227
+ return ok(unknown);
228
+ }
229
+ #validateCandidate(records, unknowns) {
230
+ if (records.size + unknowns.size > this.limits.maxRecords)
231
+ return err(new EvidenceLimitError("records", this.limits.maxRecords));
232
+ const bytes = [...records.values()].reduce((total, evidence) => total + serializedBytes(evidence), 0) +
233
+ [...unknowns.values()].reduce((total, unknown) => total + serializedBytes(unknown), 0);
234
+ if (bytes > this.limits.maxBytes)
235
+ return err(new EvidenceLimitError("bytes", this.limits.maxBytes));
236
+ try {
237
+ parseEvidenceBundle(createEvidenceBundle([...records.values()], [...unknowns.values()]));
238
+ }
239
+ catch (cause) {
240
+ return err(new UnknownRegistryError("integrity", { cause }));
241
+ }
242
+ return ok(bytes);
243
+ }
244
+ #commit(records, unknowns, bytes) {
245
+ this.#records.clear();
246
+ for (const [id, evidence] of records)
247
+ this.#records.set(id, evidence);
248
+ this.#unknownRevisions.clear();
249
+ this.#unknownHeads.clear();
250
+ for (const [key, unknown] of unknowns) {
251
+ this.#unknownRevisions.set(key, unknown);
252
+ const head = this.#unknownHeads.get(unknown.unknown_id);
253
+ if (head === undefined || head.revision < unknown.revision)
254
+ this.#unknownHeads.set(unknown.unknown_id, unknown);
255
+ }
256
+ this.#bytes = bytes;
52
257
  }
53
258
  }
259
+ const serializedBytes = (value) => Buffer.byteLength(JSON.stringify(value), "utf8");
260
+ const unknownRevisionKey = (unknown) => `${unknown.unknown_id}:${String(unknown.revision)}`;
54
261
  const recordsAgree = (left, right) => JSON.stringify(withoutLocalPath(left)) ===
55
262
  JSON.stringify(withoutLocalPath(right));
56
263
  const withoutLocalPath = (evidence) => ({
@@ -0,0 +1,124 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants as fsConstants } from "node:fs";
3
+ import { lstat, open, readdir, readlink } from "node:fs/promises";
4
+ import { dirname, join, relative, resolve } from "node:path";
5
+ const isWithin = (candidate, root) => candidate === root ||
6
+ candidate.startsWith(`${root.endsWith("/") ? root.slice(0, -1) : root}/`);
7
+ const hasSameIdentity = (before, after, expected) => before.dev === after.dev &&
8
+ before.ino === after.ino &&
9
+ (expected === "directory" ? after.isDirectory() : after.isSymbolicLink());
10
+ const lstatIfPresent = async (path) => {
11
+ try {
12
+ return await lstat(path);
13
+ }
14
+ catch (cause) {
15
+ if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
16
+ return undefined;
17
+ throw cause;
18
+ }
19
+ };
20
+ const hashFile = async (path, maxBytes, signal) => {
21
+ const handle = await open(path, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
22
+ try {
23
+ const stats = await handle.stat();
24
+ if (!stats.isFile() || stats.size > maxBytes)
25
+ return null;
26
+ const hash = createHash("sha256");
27
+ const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, maxBytes));
28
+ let position = 0;
29
+ while (position < stats.size) {
30
+ signal?.throwIfAborted();
31
+ const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, stats.size - position), position);
32
+ if (bytesRead === 0)
33
+ return null;
34
+ hash.update(buffer.subarray(0, bytesRead));
35
+ position += bytesRead;
36
+ }
37
+ const after = await handle.stat();
38
+ if (after.size !== stats.size || after.mtimeMs !== stats.mtimeMs)
39
+ return null;
40
+ return hash.digest("hex");
41
+ }
42
+ finally {
43
+ await handle.close();
44
+ }
45
+ };
46
+ /** Capture bounded, root-aliased filesystem state without following symlinks. */
47
+ export const snapshotRoots = async (scenario, signal) => {
48
+ const entries = [];
49
+ let remainingBytes = scenario.limits.file_bytes;
50
+ let truncated = false;
51
+ const visit = async (root, rootAlias, path, depth) => {
52
+ signal?.throwIfAborted();
53
+ if (entries.length >= scenario.limits.files ||
54
+ depth > scenario.limits.filesystem_depth) {
55
+ truncated = true;
56
+ return;
57
+ }
58
+ const stats = await lstatIfPresent(path);
59
+ if (stats === undefined)
60
+ return;
61
+ const relativePath = relative(root, path) || ".";
62
+ if (stats.isSymbolicLink()) {
63
+ const target = resolve(dirname(path), await readlink(path));
64
+ const afterRead = await lstat(path);
65
+ if (!hasSameIdentity(stats, afterRead, "symlink")) {
66
+ truncated = true;
67
+ return;
68
+ }
69
+ const safeTarget = isWithin(target, root)
70
+ ? relative(root, target) || "."
71
+ : "<outside-declared-root>";
72
+ if (safeTarget === "<outside-declared-root>")
73
+ truncated = true;
74
+ entries.push({
75
+ path: `${rootAlias}:${relativePath}`,
76
+ type: "symlink",
77
+ mode: stats.mode,
78
+ size: stats.size,
79
+ sha256: null,
80
+ symlink_target: safeTarget,
81
+ });
82
+ return;
83
+ }
84
+ if (stats.isFile()) {
85
+ const sha256 = remainingBytes >= stats.size
86
+ ? await hashFile(path, remainingBytes, signal)
87
+ : null;
88
+ remainingBytes -= sha256 === null ? 0 : stats.size;
89
+ if (sha256 === null)
90
+ truncated = true;
91
+ entries.push({
92
+ path: `${rootAlias}:${relativePath}`,
93
+ type: "file",
94
+ mode: stats.mode,
95
+ size: stats.size,
96
+ sha256,
97
+ symlink_target: null,
98
+ });
99
+ return;
100
+ }
101
+ const type = stats.isDirectory() ? "directory" : "other";
102
+ entries.push({
103
+ path: `${rootAlias}:${relativePath}`,
104
+ type,
105
+ mode: stats.mode,
106
+ size: stats.size,
107
+ sha256: null,
108
+ symlink_target: null,
109
+ });
110
+ if (type !== "directory")
111
+ return;
112
+ const children = await readdir(path);
113
+ const afterRead = await lstat(path);
114
+ if (!hasSameIdentity(stats, afterRead, "directory")) {
115
+ truncated = true;
116
+ return;
117
+ }
118
+ for (const child of children.sort())
119
+ await visit(root, rootAlias, join(path, child), depth + 1);
120
+ };
121
+ for (const [index, root] of scenario.filesystem_roots.entries())
122
+ await visit(root, `root_${String(index)}`, root, 0);
123
+ return { files: entries, truncated };
124
+ };