rea-agents 0.3.0 → 0.5.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 (125) hide show
  1. package/README.md +139 -21
  2. package/bridge/hopper_bridge.py +156 -14
  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 +174 -14
  8. package/dist/application/CompositeProvider.js +73 -0
  9. package/dist/application/DirectAnalysis.js +44 -3
  10. package/dist/application/Doctor.js +35 -6
  11. package/dist/application/EnhancedTools.js +10 -7
  12. package/dist/application/EvidenceBundleCommands.js +51 -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 +105 -0
  21. package/dist/application/ProcessSampling.js +284 -0
  22. package/dist/application/RealHopperAssertions.js +116 -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 +229 -21
  41. package/dist/cliEvidenceCommands.js +68 -0
  42. package/dist/cliLogging.js +21 -0
  43. package/dist/config.js +35 -1
  44. package/dist/contracts/artifactComparisonExample.js +95 -0
  45. package/dist/contracts/artifactToolContracts.js +84 -0
  46. package/dist/contracts/enhancedInputs.js +4 -0
  47. package/dist/contracts/functionComparisonExample.js +57 -0
  48. package/dist/contracts/investigationExamples.js +118 -0
  49. package/dist/contracts/nativeToolContracts.js +52 -0
  50. package/dist/contracts/processCaptureExample.js +25 -0
  51. package/dist/contracts/toolContractExamples.js +69 -0
  52. package/dist/contracts/toolContracts.js +99 -36
  53. package/dist/contracts/toolOutputSchemas.js +156 -82
  54. package/dist/contracts/unknownContractExamples.js +33 -0
  55. package/dist/domain/artifactComparison.js +273 -0
  56. package/dist/domain/artifactGraph.js +194 -0
  57. package/dist/domain/artifactInventoryEvidence.js +150 -0
  58. package/dist/domain/binaryTarget.js +55 -0
  59. package/dist/domain/bundleComparison.js +266 -0
  60. package/dist/domain/callPath.js +346 -0
  61. package/dist/domain/changedBehavior.js +294 -0
  62. package/dist/domain/errors.js +199 -5
  63. package/dist/domain/evidence.js +41 -10
  64. package/dist/domain/evidenceBundle.js +187 -6
  65. package/dist/domain/functionComparison.js +201 -0
  66. package/dist/domain/functionComparisonNormalization.js +112 -0
  67. package/dist/domain/functionComparisonResults.js +54 -0
  68. package/dist/domain/functionComparisonSchemas.js +82 -0
  69. package/dist/domain/functionDossierEvidence.js +171 -0
  70. package/dist/domain/hopperValues.js +35 -8
  71. package/dist/domain/nativeInspection.js +142 -0
  72. package/dist/domain/processCapture.js +152 -54
  73. package/dist/domain/processComparison.js +106 -0
  74. package/dist/domain/reconstructionUnknowns.js +90 -0
  75. package/dist/domain/reconstructionVerification.js +285 -0
  76. package/dist/domain/reconstructionVerificationSchemas.js +126 -0
  77. package/dist/domain/referenceSourceClassification.js +496 -0
  78. package/dist/domain/referenceSourceGraph.js +376 -0
  79. package/dist/domain/referenceSourceImportParsing.js +235 -0
  80. package/dist/domain/referenceSourcePolicy.js +1 -0
  81. package/dist/domain/residualUnknown.js +239 -0
  82. package/dist/domain/staticRuntimeCorrelation.js +375 -0
  83. package/dist/hopper/BridgeLauncher.js +39 -3
  84. package/dist/hopper/HopperClient.js +14 -5
  85. package/dist/hopper/HopperProvider.js +57 -22
  86. package/dist/hopper/protocol.js +13 -2
  87. package/dist/identity.js +1 -0
  88. package/dist/main.js +5 -1
  89. package/dist/native/CommandRunner.js +156 -0
  90. package/dist/native/NativeMacOSProvider.js +306 -0
  91. package/dist/native/NativeMachoInspection.js +135 -0
  92. package/dist/native/parsers/codesign.js +55 -0
  93. package/dist/native/parsers/demangle.js +26 -0
  94. package/dist/native/parsers/dyldInfo.js +25 -0
  95. package/dist/native/parsers/lipo.js +67 -0
  96. package/dist/native/parsers/otool.js +193 -0
  97. package/dist/native/parsers/plist.js +23 -0
  98. package/dist/reference/ReferenceSourceReader.js +73 -0
  99. package/dist/reference/ReferenceSourceReaderEntries.js +206 -0
  100. package/dist/reference/ReferenceSourceReaderErrors.js +19 -0
  101. package/dist/reference/ReferenceSourceReaderFile.js +119 -0
  102. package/dist/reference/ReferenceSourceReaderPaths.js +23 -0
  103. package/dist/reference/ReferenceSourceReaderTypes.js +2 -0
  104. package/dist/reference/ReferenceSourceReaderValidate.js +71 -0
  105. package/dist/server/createServer.js +31 -5
  106. package/dist/server/recordDerivedEvidence.js +10 -0
  107. package/dist/server/registerArtifactComparisonTool.js +62 -0
  108. package/dist/server/registerArtifactTools.js +6 -0
  109. package/dist/server/registerBundleComparisonTool.js +47 -0
  110. package/dist/server/registerEnhancedTools.js +64 -12
  111. package/dist/server/registerEvidenceTools.js +36 -0
  112. package/dist/server/registerFunctionComparisonTool.js +68 -0
  113. package/dist/server/registerInvestigationTools.js +224 -0
  114. package/dist/server/registerNativeTools.js +6 -0
  115. package/dist/server/registerOfficialTools.js +47 -14
  116. package/dist/server/registerProcessComparisonTool.js +106 -0
  117. package/dist/server/registerSessionTools.js +179 -70
  118. package/dist/server/sessionEvidence.js +28 -0
  119. package/dist/server/sessionToolPolicies.js +64 -0
  120. package/dist/server/toolRegistrationOptions.js +7 -0
  121. package/dist/server/toolResult.js +8 -5
  122. package/install.sh +198 -0
  123. package/package.json +18 -1
  124. package/scripts/rea.mjs +5 -1
  125. package/skills/rea-analysis/SKILL.md +77 -2
@@ -0,0 +1,166 @@
1
+ import { lstat, realpath } from "node:fs/promises";
2
+ import { AsarArtifactReader } from "../artifacts/AsarArtifactReader.js";
3
+ import { ArtifactPathRegistry, normalizeArtifactPath, } from "../artifacts/ArtifactPaths.js";
4
+ import { ArtifactReaderFailure, } from "../artifacts/ArtifactReader.js";
5
+ import { DirectoryArtifactReader } from "../artifacts/DirectoryArtifactReader.js";
6
+ import { SafeOutputTree } from "../artifacts/SafeOutputTree.js";
7
+ import { ZipArtifactReader } from "../artifacts/ZipArtifactReader.js";
8
+ import { MachOSliceArtifactReader } from "../artifacts/MachOSliceArtifactReader.js";
9
+ import { artifactExtractionResultSchema, } from "../domain/artifactGraph.js";
10
+ import { scanArtifactInventory } from "./ArtifactInventory.js";
11
+ import { digestCanonical, pageOf, toOutputLimits, } from "./ArtifactGraphConstruction.js";
12
+ const PAGE_SIZE = 500;
13
+ /** Extract selected inventory occurrences into an exclusively owned absent root. */
14
+ export const extractArtifact = async (input, signal) => {
15
+ validateSelection(input.occurrenceIds);
16
+ const sourcePath = await realpath(input.inputPath);
17
+ const selectedIds = new Set(input.occurrenceIds);
18
+ const inventory = await loadInventory(sourcePath, input.limits, selectedIds, signal);
19
+ const selected = input.occurrenceIds.map((id) => {
20
+ const occurrence = inventory.occurrences.get(id);
21
+ if (occurrence === undefined)
22
+ throw new ArtifactReaderFailure("unavailable", `Selected artifact occurrence was not found: ${id}`);
23
+ if ((occurrence.entry_kind !== "file" && occurrence.entry_kind !== "slice") ||
24
+ occurrence.artifact_id === null ||
25
+ occurrence.encrypted ||
26
+ occurrence.logical_path === ".")
27
+ throw new ArtifactReaderFailure("format", `Selected occurrence is not an extractable regular child file: ${id}`);
28
+ const node = inventory.nodes.get(occurrence.artifact_id);
29
+ if (node === undefined)
30
+ throw new ArtifactReaderFailure("integrity", `Selected occurrence has no inventory node: ${id}`);
31
+ return { occurrence, node };
32
+ });
33
+ return materializeSelection({
34
+ input,
35
+ sourcePath,
36
+ inventory,
37
+ selected,
38
+ signal,
39
+ });
40
+ };
41
+ const materializeSelection = async ({ input, sourcePath, inventory, selected, signal, }) => {
42
+ const byPath = new Map(selected.map((item) => [item.occurrence.logical_path, item]));
43
+ const reader = await createReader(sourcePath, input.inputFormat);
44
+ const output = await SafeOutputTree.create(input.outputRoot, input.limits);
45
+ let readerClosed = false;
46
+ const extracted = [];
47
+ try {
48
+ const found = new Set();
49
+ const registry = new ArtifactPathRegistry();
50
+ let entryCount = 0;
51
+ for await (const entry of reader.entries(signal)) {
52
+ entryCount += 1;
53
+ if (entryCount > input.limits.maxEntries)
54
+ throw new ArtifactReaderFailure("limit", "Artifact entry limit exceeded during extraction");
55
+ const path = normalizeArtifactPath(entry.path, input.limits);
56
+ registry.add(path, entry.kind);
57
+ const selectedItem = byPath.get(path);
58
+ if (selectedItem === undefined)
59
+ continue;
60
+ preflight(entry, input.limits);
61
+ const stream = await reader.open(entry, signal);
62
+ const written = await output.write(path, stream, selectedItem.node.sha256, signal);
63
+ extracted.push({
64
+ artifact_id: selectedItem.node.artifact_id,
65
+ relative_path: written.relativePath,
66
+ sha256: written.sha256,
67
+ bytes_written: written.bytesWritten,
68
+ created: true,
69
+ });
70
+ found.add(path);
71
+ }
72
+ if (found.size !== selected.length)
73
+ throw new ArtifactReaderFailure("integrity", "Selected inventory occurrence disappeared before extraction");
74
+ await reader.close();
75
+ readerClosed = true;
76
+ extracted.sort((left, right) => left.relative_path.localeCompare(right.relative_path, "en"));
77
+ const result = createExtractionResult(input, inventory, selected, extracted);
78
+ await output.commit();
79
+ return result;
80
+ }
81
+ catch (cause) {
82
+ if (!readerClosed)
83
+ await reader.close().catch(() => undefined);
84
+ await output.rollback();
85
+ throw cause;
86
+ }
87
+ };
88
+ const createExtractionResult = (input, inventory, selected, extracted) => {
89
+ const extractionSemantic = {
90
+ schema_version: 1,
91
+ source_manifest_id: inventory.manifest.manifest_id,
92
+ selected_occurrence_ids: selected
93
+ .map(({ occurrence }) => occurrence.occurrence_id)
94
+ .sort((left, right) => left.localeCompare(right)),
95
+ files_sha256: digestCanonical(extracted),
96
+ output_root_alias: "$OUTPUT_ROOT",
97
+ };
98
+ return artifactExtractionResultSchema.parse({
99
+ manifest: inventory.manifest,
100
+ extraction_manifest: {
101
+ ...extractionSemantic,
102
+ extraction_id: `aex_${digestCanonical(extractionSemantic)}`,
103
+ },
104
+ output_root: "$OUTPUT_ROOT",
105
+ artifacts: pageOf(extracted, input.offset, input.limit),
106
+ containment_verified: true,
107
+ cleanup: { attempted: false, verified: true, residual_paths: [] },
108
+ limits: toOutputLimits(input.limits),
109
+ provenance: [],
110
+ limitations: [
111
+ "Only caller-selected regular file occurrences were materialized.",
112
+ ],
113
+ });
114
+ };
115
+ const loadInventory = async (path, limits, selectedIds, signal) => {
116
+ const snapshot = await scanArtifactInventory(path, limits, signal);
117
+ const occurrences = new Map();
118
+ const neededNodes = new Set();
119
+ collectOccurrences(snapshot.occurrences, selectedIds, occurrences, neededNodes);
120
+ const nodes = new Map();
121
+ collectNodes(snapshot.nodes, neededNodes, nodes);
122
+ return { manifest: snapshot.manifest, occurrences, nodes };
123
+ };
124
+ const collectOccurrences = (items, selected, output, neededNodes) => {
125
+ for (const item of items) {
126
+ if (!selected.has(item.occurrence_id))
127
+ continue;
128
+ output.set(item.occurrence_id, item);
129
+ if (item.artifact_id !== null)
130
+ neededNodes.add(item.artifact_id);
131
+ }
132
+ };
133
+ const collectNodes = (items, selected, output) => {
134
+ for (const item of items)
135
+ if (selected.has(item.artifact_id))
136
+ output.set(item.artifact_id, item);
137
+ };
138
+ const createReader = async (path, format) => {
139
+ if ((await lstat(path)).isDirectory())
140
+ return new DirectoryArtifactReader(path);
141
+ if (format === "asar")
142
+ return new AsarArtifactReader(path);
143
+ if (format === "ipa" || format === "apk" || format === "zip")
144
+ return new ZipArtifactReader(path, format);
145
+ if (format === "mach-o")
146
+ return new MachOSliceArtifactReader(path);
147
+ throw new ArtifactReaderFailure("unavailable", `Artifact format has no extraction reader: ${format}`);
148
+ };
149
+ const validateSelection = (ids) => {
150
+ if (ids.length === 0 || ids.length > PAGE_SIZE)
151
+ throw new ArtifactReaderFailure("limit", "Extraction requires 1 to 500 explicitly selected occurrences");
152
+ if (new Set(ids).size !== ids.length)
153
+ throw new ArtifactReaderFailure("path", "Extraction occurrence selection contains duplicates");
154
+ };
155
+ const preflight = (entry, limits) => {
156
+ if ((entry.kind !== "file" && entry.kind !== "slice") || entry.encrypted)
157
+ throw new ArtifactReaderFailure("format", `Selected artifact entry cannot be read: ${entry.path}`);
158
+ if (entry.declaredSize !== null && entry.declaredSize > limits.maxEntryBytes)
159
+ throw new ArtifactReaderFailure("limit", `Selected artifact exceeds byte limit: ${entry.path}`);
160
+ if (entry.declaredSize !== null &&
161
+ entry.compressedSize !== null &&
162
+ (entry.compressedSize === 0
163
+ ? entry.declaredSize > 0
164
+ : entry.declaredSize / entry.compressedSize > limits.maxCompressionRatio))
165
+ throw new ArtifactReaderFailure("limit", `Selected artifact exceeds compression ratio limit: ${entry.path}`);
166
+ };
@@ -0,0 +1,257 @@
1
+ import { createHash } from "node:crypto";
2
+ import { extname } from "node:path";
3
+ import canonicalize from "canonicalize";
4
+ export const createOccurrence = (entry, path, parent) => ({
5
+ occurrence_id: `occ_${digestCanonical({ path, kind: entry.kind })}`,
6
+ artifact_id: null,
7
+ parent_occurrence_id: parent,
8
+ logical_path: path,
9
+ entry_kind: entry.kind,
10
+ declared_size: entry.declaredSize,
11
+ compressed_size: entry.compressedSize,
12
+ executable: entry.executable,
13
+ encrypted: entry.encrypted,
14
+ hash_status: entry.encrypted ? "unavailable" : "not-hashed-limit",
15
+ source_location: entry.byteOffset === null || entry.declaredSize === null
16
+ ? null
17
+ : { offset: entry.byteOffset, length: entry.declaredSize },
18
+ limitations: [...entry.limitations],
19
+ });
20
+ export const materializeDirectoryNodes = (occurrences, nodes) => {
21
+ const directories = occurrences
22
+ .filter(({ entry_kind: kind }) => kind === "directory")
23
+ .sort((left, right) => depth(right.logical_path) - depth(left.logical_path));
24
+ const childrenByParent = new Map();
25
+ for (const occurrence of occurrences) {
26
+ if (occurrence.parent_occurrence_id === null)
27
+ continue;
28
+ const children = childrenByParent.get(occurrence.parent_occurrence_id);
29
+ if (children === undefined)
30
+ childrenByParent.set(occurrence.parent_occurrence_id, [occurrence]);
31
+ else
32
+ children.push(occurrence);
33
+ }
34
+ for (const directory of directories) {
35
+ const children = (childrenByParent.get(directory.occurrence_id) ?? [])
36
+ .map(({ logical_path, artifact_id, entry_kind }) => ({
37
+ name: logical_path.split("/").at(-1),
38
+ artifact_id,
39
+ entry_kind,
40
+ }))
41
+ .sort((left, right) => String(left.name).localeCompare(String(right.name)));
42
+ const node = createArtifactNode({
43
+ sha256: digestCanonical({ kind: "directory", children }),
44
+ size: 0,
45
+ kind: directory.logical_path.toLowerCase().endsWith(".framework")
46
+ ? "framework"
47
+ : "container",
48
+ format: "directory",
49
+ executable: false,
50
+ contentState: "virtual",
51
+ });
52
+ const existing = nodes.get(node.artifact_id);
53
+ nodes.set(node.artifact_id, existing?.kind === "framework" ? existing : node);
54
+ directory.artifact_id = node.artifact_id;
55
+ directory.hash_status = "verified";
56
+ }
57
+ };
58
+ export const createRootNode = (input) => createArtifactNode({
59
+ sha256: input.digest?.sha256 ??
60
+ digestCanonical({
61
+ kind: "directory-root",
62
+ children: input.occurrences.map(({ logical_path, artifact_id }) => ({
63
+ logical_path,
64
+ artifact_id,
65
+ })),
66
+ }),
67
+ size: input.digest?.bytes ?? 0,
68
+ kind: input.directory ||
69
+ ["zip", "ipa", "apk", "asar", "dmg", "pkg"].includes(input.format)
70
+ ? "container"
71
+ : classifyArtifactPath(input.path).kind,
72
+ format: input.format,
73
+ executable: false,
74
+ contentState: "materialized",
75
+ });
76
+ export const createArtifactNode = (input) => ({
77
+ artifact_id: `art_${digestCanonical({ schema_version: 1, sha256: input.sha256 })}`,
78
+ kind: input.kind,
79
+ format: input.format,
80
+ sha256: input.sha256,
81
+ size: input.size,
82
+ media_type: null,
83
+ architecture: null,
84
+ executable: input.executable,
85
+ content_state: input.contentState,
86
+ limitations: [],
87
+ });
88
+ export const rootOccurrenceFor = (node, declaredSize) => ({
89
+ occurrence_id: `occ_${digestCanonical({ root: node.artifact_id })}`,
90
+ artifact_id: node.artifact_id,
91
+ parent_occurrence_id: null,
92
+ logical_path: ".",
93
+ entry_kind: node.format === "directory" ? "directory" : "file",
94
+ declared_size: declaredSize,
95
+ compressed_size: null,
96
+ executable: node.executable,
97
+ encrypted: false,
98
+ hash_status: "verified",
99
+ source_location: null,
100
+ limitations: [],
101
+ });
102
+ export const rekeyOccurrences = (rootArtifactId, occurrences) => {
103
+ const replacements = new Map();
104
+ for (const occurrence of occurrences)
105
+ replacements.set(occurrence.occurrence_id, `occ_${digestCanonical({
106
+ root_artifact_id: rootArtifactId,
107
+ logical_path: occurrence.logical_path,
108
+ entry_kind: occurrence.entry_kind,
109
+ })}`);
110
+ for (const occurrence of occurrences) {
111
+ occurrence.occurrence_id =
112
+ replacements.get(occurrence.occurrence_id) ?? occurrence.occurrence_id;
113
+ if (occurrence.parent_occurrence_id !== null)
114
+ occurrence.parent_occurrence_id =
115
+ replacements.get(occurrence.parent_occurrence_id) ??
116
+ occurrence.parent_occurrence_id;
117
+ }
118
+ };
119
+ export const createArtifactEdges = (rootArtifactId, occurrences, producer) => {
120
+ const byId = new Map(occurrences.map((item) => [item.occurrence_id, item]));
121
+ const edges = [];
122
+ for (const occurrence of occurrences) {
123
+ if (occurrence.parent_occurrence_id === null ||
124
+ occurrence.artifact_id === null)
125
+ continue;
126
+ const mappedSource = occurrence.logical_path.endsWith(".map")
127
+ ? occurrences.find(({ logical_path: path }) => path === occurrence.logical_path.slice(0, -".map".length))
128
+ : undefined;
129
+ const parentArtifactId = mappedSource?.artifact_id ??
130
+ byId.get(occurrence.parent_occurrence_id)?.artifact_id ??
131
+ rootArtifactId;
132
+ const semantic = {
133
+ parent_artifact_id: parentArtifactId,
134
+ child_artifact_id: occurrence.artifact_id,
135
+ relation: occurrence.entry_kind === "slice"
136
+ ? "slice-of"
137
+ : relationFor(occurrence.logical_path),
138
+ occurrence_id: occurrence.occurrence_id,
139
+ logical_path: occurrence.logical_path,
140
+ };
141
+ edges.push({
142
+ edge_id: `edge_${digestCanonical(semantic)}`,
143
+ ...semantic,
144
+ producer: occurrence.entry_kind === "slice" ? (producer ?? null) : null,
145
+ ordinal: edges.length,
146
+ });
147
+ }
148
+ return edges;
149
+ };
150
+ export const nearestParent = (path, occurrences) => {
151
+ const parts = path.split("/");
152
+ while (parts.length > 1) {
153
+ parts.pop();
154
+ const candidate = occurrences.get(parts.join("/"));
155
+ if (candidate?.entry_kind === "directory")
156
+ return candidate;
157
+ }
158
+ return undefined;
159
+ };
160
+ export const classifyArtifactPath = (path) => {
161
+ const lower = path.toLowerCase();
162
+ if (lower.endsWith(".map"))
163
+ return { kind: "source-map", format: "source-map" };
164
+ if (/\.(?:m?js|cjs)$/u.test(lower))
165
+ return { kind: "javascript", format: "javascript-bundle" };
166
+ if (lower.endsWith(".asar"))
167
+ return { kind: "container", format: "asar" };
168
+ const archiveFormat = archiveFormatForExtension(extname(lower));
169
+ if (archiveFormat !== undefined)
170
+ return { kind: "container", format: archiveFormat };
171
+ if (/\.framework(?:\/|$)/u.test(lower))
172
+ return { kind: "framework", format: "file" };
173
+ if (/\.(?:node|dylib|so)$/u.test(lower))
174
+ return {
175
+ kind: lower.endsWith(".node") ? "native-addon" : "dynamic-library",
176
+ format: "file",
177
+ };
178
+ if (lower.endsWith(".plist"))
179
+ return { kind: "plist", format: "plist" };
180
+ if (lower.endsWith(".entitlements"))
181
+ return { kind: "entitlements", format: "entitlements" };
182
+ return { kind: "resource", format: "file" };
183
+ };
184
+ /** Classify executable formats from bytes while retaining path-specific roles. */
185
+ export const classifyArtifactContent = (path, prefix) => {
186
+ const byPath = classifyArtifactPath(path);
187
+ if (prefix.length >= 4) {
188
+ const magic = prefix.readUInt32BE(0);
189
+ if ([0xcafebabe, 0xbebafeca, 0xcafebabf, 0xbfbafeca].includes(magic))
190
+ return {
191
+ kind: byPath.kind === "native-addon" ? "native-addon" : "executable",
192
+ format: "mach-o-universal",
193
+ };
194
+ if ([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic))
195
+ return {
196
+ kind: ["native-addon", "dynamic-library"].includes(byPath.kind)
197
+ ? byPath.kind
198
+ : "executable",
199
+ format: "mach-o",
200
+ };
201
+ if (prefix.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])))
202
+ return {
203
+ kind: ["native-addon", "dynamic-library"].includes(byPath.kind)
204
+ ? byPath.kind
205
+ : "executable",
206
+ format: "elf",
207
+ };
208
+ }
209
+ if (prefix.length >= 2 && prefix[0] === 0x4d && prefix[1] === 0x5a)
210
+ return {
211
+ kind: byPath.kind === "native-addon" ? "native-addon" : "executable",
212
+ format: "pe",
213
+ };
214
+ return byPath;
215
+ };
216
+ export const pageOf = (items, offset, limit) => ({
217
+ items: items.slice(offset, offset + limit),
218
+ offset,
219
+ limit,
220
+ total: items.length,
221
+ next_offset: offset + limit < items.length ? offset + limit : null,
222
+ });
223
+ export const toOutputLimits = (limits) => ({
224
+ max_entries: limits.maxEntries,
225
+ max_total_bytes: limits.maxTotalBytes,
226
+ max_entry_bytes: limits.maxEntryBytes,
227
+ max_compression_ratio: limits.maxCompressionRatio,
228
+ max_depth: limits.maxDepth,
229
+ max_path_bytes: limits.maxPathBytes,
230
+ });
231
+ export const digestCanonical = (value) => {
232
+ const encoded = canonicalize(value);
233
+ if (encoded === undefined)
234
+ throw new TypeError("Artifact value is not canonical JSON");
235
+ return createHash("sha256").update(encoded).digest("hex");
236
+ };
237
+ const relationFor = (path) => {
238
+ const lower = path.toLowerCase();
239
+ if (lower.endsWith(".map"))
240
+ return "maps-source";
241
+ if (lower.includes(".framework/"))
242
+ return "embeds";
243
+ return "contains";
244
+ };
245
+ const archiveFormatForExtension = (extension) => {
246
+ switch (extension) {
247
+ case ".zip":
248
+ return "zip";
249
+ case ".ipa":
250
+ return "ipa";
251
+ case ".apk":
252
+ return "apk";
253
+ default:
254
+ return undefined;
255
+ }
256
+ };
257
+ const depth = (path) => path.split("/").length;
@@ -0,0 +1,253 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createReadStream } from "node:fs";
3
+ import { lstat, open, realpath } from "node:fs/promises";
4
+ import { AsarArtifactReader } from "../artifacts/AsarArtifactReader.js";
5
+ import { ArtifactPathRegistry, normalizeArtifactPath, } from "../artifacts/ArtifactPaths.js";
6
+ import { ArtifactReaderFailure, } from "../artifacts/ArtifactReader.js";
7
+ import { DirectoryArtifactReader } from "../artifacts/DirectoryArtifactReader.js";
8
+ import { ZipArtifactReader } from "../artifacts/ZipArtifactReader.js";
9
+ import { MachOSliceArtifactReader } from "../artifacts/MachOSliceArtifactReader.js";
10
+ import { streamChunkToBuffer } from "../artifacts/StreamBytes.js";
11
+ import { artifactInventoryResultSchema, } from "../domain/artifactGraph.js";
12
+ import { classifyArtifactPath, classifyArtifactContent, createArtifactEdges, createArtifactNode, createOccurrence, createRootNode, digestCanonical, materializeDirectoryNodes, nearestParent, pageOf, rekeyOccurrences, rootOccurrenceFor, toOutputLimits, } from "./ArtifactGraphConstruction.js";
13
+ /** Inventory one local artifact without extracting or mounting it. */
14
+ export const inventoryArtifact = async (inputPath, limits, page, signal) => {
15
+ const snapshot = await scanArtifactInventory(inputPath, limits, signal);
16
+ return paginateArtifactInventory(snapshot, page);
17
+ };
18
+ /** Scan an artifact once and retain the complete immutable graph for projection. */
19
+ export const scanArtifactInventory = async (inputPath, limits, signal) => {
20
+ abortIfNeeded(signal);
21
+ const path = await realpath(inputPath);
22
+ const metadata = await lstat(path);
23
+ const rootFormat = await classifyRoot(path, metadata.isDirectory());
24
+ const rootDigest = metadata.isDirectory()
25
+ ? null
26
+ : await hashReadable(createReadStream(path), limits.maxTotalBytes, signal);
27
+ const reader = await createReader(path, rootFormat);
28
+ try {
29
+ const { nodes, occurrences } = await scanReader(reader, limits, signal);
30
+ materializeDirectoryNodes(occurrences, nodes);
31
+ const rootNode = createRootNode({
32
+ path,
33
+ format: rootFormat,
34
+ directory: metadata.isDirectory(),
35
+ digest: rootDigest,
36
+ occurrences,
37
+ });
38
+ nodes.set(rootNode.artifact_id, rootNode);
39
+ rekeyOccurrences(rootNode.artifact_id, occurrences);
40
+ const rootOccurrence = rootOccurrenceFor(rootNode, metadata.size);
41
+ for (const occurrence of occurrences)
42
+ if (occurrence.parent_occurrence_id === null)
43
+ occurrence.parent_occurrence_id = rootOccurrence.occurrence_id;
44
+ occurrences.unshift(rootOccurrence);
45
+ const edges = createArtifactEdges(rootNode.artifact_id, occurrences, reader?.provenance()[0]);
46
+ const orderedNodes = [...nodes.values()].sort((left, right) => left.artifact_id.localeCompare(right.artifact_id));
47
+ const orderedOccurrences = occurrences.sort((left, right) => left.logical_path.localeCompare(right.logical_path, "en"));
48
+ const orderedEdges = edges.sort((left, right) => left.edge_id.localeCompare(right.edge_id));
49
+ if (rootDigest !== null) {
50
+ const verified = await hashReadable(createReadStream(path), limits.maxTotalBytes, signal);
51
+ if (verified.sha256 !== rootDigest.sha256 ||
52
+ verified.bytes !== rootDigest.bytes)
53
+ throw new ArtifactReaderFailure("integrity", "Root artifact changed during inventory");
54
+ }
55
+ const graphSha256 = digestCanonical({
56
+ nodes: orderedNodes,
57
+ occurrences: orderedOccurrences,
58
+ edges: orderedEdges,
59
+ });
60
+ const manifestId = `agm_${digestCanonical({
61
+ schema_version: 1,
62
+ root_artifact_id: rootNode.artifact_id,
63
+ graph_sha256: graphSha256,
64
+ })}`;
65
+ return {
66
+ manifest: artifactInventoryResultSchema.shape.manifest.parse({
67
+ schema_version: 1,
68
+ manifest_id: manifestId,
69
+ root_artifact_id: rootNode.artifact_id,
70
+ root_sha256: rootNode.sha256,
71
+ root_format: rootNode.format,
72
+ graph_sha256: graphSha256,
73
+ node_count: orderedNodes.length,
74
+ occurrence_count: orderedOccurrences.length,
75
+ edge_count: orderedEdges.length,
76
+ }),
77
+ nodes: orderedNodes,
78
+ occurrences: orderedOccurrences,
79
+ edges: orderedEdges,
80
+ limits: toOutputLimits(limits),
81
+ provenance: reader?.provenance() ?? [],
82
+ limitations: inventoryLimitations(rootFormat, reader),
83
+ };
84
+ }
85
+ finally {
86
+ await reader?.close();
87
+ }
88
+ };
89
+ /** Project independently paged graph collections from one inventory snapshot. */
90
+ export const paginateArtifactInventory = (snapshot, page) => artifactInventoryResultSchema.parse({
91
+ ...snapshot,
92
+ nodes: pageOf(snapshot.nodes, page.nodeOffset, page.nodeLimit),
93
+ occurrences: pageOf(snapshot.occurrences, page.occurrenceOffset, page.occurrenceLimit),
94
+ edges: pageOf(snapshot.edges, page.edgeOffset, page.edgeLimit),
95
+ });
96
+ const inventoryLimitations = (format, reader) => {
97
+ if (reader !== undefined)
98
+ return [];
99
+ if (format === "dmg" || format === "pkg")
100
+ return [
101
+ `${format.toUpperCase()} root hash is observed; child inventory requires an approved native macOS adapter.`,
102
+ ];
103
+ if (format === "mach-o-universal" && process.platform !== "darwin")
104
+ return ["Universal Mach-O slices require the native macOS lipo adapter."];
105
+ return ["Artifact has no child container entries."];
106
+ };
107
+ const createReader = async (path, format) => {
108
+ switch (format) {
109
+ case "directory":
110
+ return new DirectoryArtifactReader(path);
111
+ case "zip":
112
+ case "ipa":
113
+ case "apk":
114
+ return new ZipArtifactReader(path, format);
115
+ case "asar":
116
+ return new AsarArtifactReader(path);
117
+ case "mach-o-universal":
118
+ return process.platform === "darwin"
119
+ ? new MachOSliceArtifactReader(path)
120
+ : undefined;
121
+ default:
122
+ return undefined;
123
+ }
124
+ };
125
+ const scanReader = async (reader, limits, signal) => {
126
+ const nodes = new Map();
127
+ const occurrences = [];
128
+ if (reader === undefined)
129
+ return { nodes, occurrences };
130
+ const occurrenceByPath = new Map();
131
+ const registry = new ArtifactPathRegistry();
132
+ let totalBytes = 0;
133
+ for await (const entry of reader.entries(signal)) {
134
+ if (occurrences.length >= limits.maxEntries)
135
+ throw new ArtifactReaderFailure("limit", "Artifact entry limit exceeded");
136
+ const logicalPath = normalizeArtifactPath(entry.path, limits);
137
+ registry.add(logicalPath, entry.kind);
138
+ preflightEntry(entry, limits);
139
+ const parent = nearestParent(logicalPath, occurrenceByPath);
140
+ const occurrence = createOccurrence(entry, logicalPath, parent?.occurrence_id ?? null);
141
+ if ((entry.kind === "file" || entry.kind === "slice") && !entry.encrypted) {
142
+ const remainingBytes = limits.maxTotalBytes - totalBytes;
143
+ if (remainingBytes <= 0)
144
+ throw new ArtifactReaderFailure("limit", "Artifact total byte limit exceeded");
145
+ if (entry.declaredSize !== null && entry.declaredSize > remainingBytes)
146
+ throw new ArtifactReaderFailure("limit", "Declared artifact bytes exceed remaining cumulative limit");
147
+ const digest = await hashReadable(await reader.open(entry, signal), Math.min(limits.maxEntryBytes, remainingBytes), signal);
148
+ totalBytes += digest.bytes;
149
+ if (entry.declaredSha256 !== null &&
150
+ entry.declaredSha256 !== digest.sha256)
151
+ throw new ArtifactReaderFailure("integrity", `Artifact integrity metadata disagrees with content: ${logicalPath}`);
152
+ if (totalBytes > limits.maxTotalBytes)
153
+ throw new ArtifactReaderFailure("limit", "Artifact total byte limit exceeded");
154
+ const classified = entry.kind === "slice"
155
+ ? { kind: "universal-slice", format: "mach-o" }
156
+ : classifyArtifactContent(logicalPath, digest.prefix);
157
+ const node = createArtifactNode({
158
+ sha256: digest.sha256,
159
+ size: digest.bytes,
160
+ kind: classified.kind,
161
+ format: classified.format,
162
+ executable: entry.executable,
163
+ contentState: "embedded",
164
+ });
165
+ nodes.set(node.artifact_id, node);
166
+ occurrence.artifact_id = node.artifact_id;
167
+ occurrence.hash_status = "verified";
168
+ }
169
+ occurrences.push(occurrence);
170
+ occurrenceByPath.set(logicalPath, occurrence);
171
+ }
172
+ return { nodes, occurrences };
173
+ };
174
+ const classifyRoot = async (path, directory) => {
175
+ if (directory)
176
+ return "directory";
177
+ const extensionFormat = classifyContainerExtension(path);
178
+ if (extensionFormat !== undefined)
179
+ return extensionFormat;
180
+ const handle = await open(path, "r");
181
+ try {
182
+ const magic = Buffer.alloc(4);
183
+ const observed = await handle.read(magic, 0, magic.length, 0);
184
+ if (observed.bytesRead === 4 &&
185
+ magic[0] === 0x50 &&
186
+ magic[1] === 0x4b &&
187
+ [0x03, 0x05, 0x07].includes(magic[2] ?? -1) &&
188
+ [0x04, 0x06, 0x08].includes(magic[3] ?? -1))
189
+ return "zip";
190
+ if (observed.bytesRead === 4) {
191
+ const header = magic.readUInt32BE(0);
192
+ if ([0xcafebabe, 0xbebafeca, 0xcafebabf, 0xbfbafeca].includes(header))
193
+ return "mach-o-universal";
194
+ if ([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(header))
195
+ return "mach-o";
196
+ if (magic.equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])))
197
+ return "elf";
198
+ }
199
+ if (observed.bytesRead >= 2 && magic[0] === 0x4d && magic[1] === 0x5a)
200
+ return "pe";
201
+ }
202
+ finally {
203
+ await handle.close();
204
+ }
205
+ return classifyArtifactPath(path).format;
206
+ };
207
+ const classifyContainerExtension = (path) => {
208
+ const lower = path.toLowerCase();
209
+ for (const format of ["asar", "ipa", "apk", "zip", "dmg", "pkg"])
210
+ if (lower.endsWith(`.${format}`))
211
+ return format;
212
+ return undefined;
213
+ };
214
+ const preflightEntry = (entry, limits) => {
215
+ if (entry.declaredSize !== null && entry.declaredSize > limits.maxEntryBytes)
216
+ throw new ArtifactReaderFailure("limit", `Entry exceeds byte limit: ${entry.path}`);
217
+ if (entry.declaredSize !== null &&
218
+ entry.compressedSize !== null &&
219
+ entry.compressedSize === 0 &&
220
+ entry.declaredSize > 0)
221
+ throw new ArtifactReaderFailure("limit", `Invalid compression ratio: ${entry.path}`);
222
+ if (entry.declaredSize !== null &&
223
+ entry.compressedSize !== null &&
224
+ entry.compressedSize > 0 &&
225
+ entry.declaredSize / entry.compressedSize > limits.maxCompressionRatio)
226
+ throw new ArtifactReaderFailure("limit", `Compression ratio exceeds limit: ${entry.path}`);
227
+ };
228
+ const hashReadable = async (stream, maximum, signal) => {
229
+ const hash = createHash("sha256");
230
+ const prefixes = [];
231
+ let prefixBytes = 0;
232
+ let bytes = 0;
233
+ for await (const raw of stream) {
234
+ abortIfNeeded(signal);
235
+ const chunk = streamChunkToBuffer(raw);
236
+ bytes += chunk.length;
237
+ if (bytes > maximum) {
238
+ stream.destroy();
239
+ throw new ArtifactReaderFailure("limit", "Observed entry bytes exceed limit");
240
+ }
241
+ hash.update(chunk);
242
+ if (prefixBytes < 16) {
243
+ const selected = chunk.subarray(0, 16 - prefixBytes);
244
+ prefixes.push(selected);
245
+ prefixBytes += selected.length;
246
+ }
247
+ }
248
+ return { sha256: hash.digest("hex"), bytes, prefix: Buffer.concat(prefixes) };
249
+ };
250
+ const abortIfNeeded = (signal) => {
251
+ if (signal?.aborted === true)
252
+ throw new ArtifactReaderFailure("cancelled", "Artifact inventory cancelled");
253
+ };