rea-agents 1.1.0 → 1.3.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 (103) hide show
  1. package/README.md +94 -28
  2. package/bridge/hopper_bridge.py +161 -12
  3. package/dist/application/AnalysisSnapshotCache.js +131 -0
  4. package/dist/application/AnalysisSnapshotFiles.js +31 -0
  5. package/dist/application/ArtifactInventory.js +84 -44
  6. package/dist/application/AuthorizedArtifactInventory.js +16 -0
  7. package/dist/application/BinarySession.js +66 -36
  8. package/dist/application/BinarySessionPort.js +1 -0
  9. package/dist/application/BoundedJsonFiles.js +109 -0
  10. package/dist/application/CommandShimReplay.js +136 -0
  11. package/dist/application/CrossVersionInventory.js +98 -0
  12. package/dist/application/CrossVersionInvestigation.js +295 -0
  13. package/dist/application/DirectAnalysis.js +115 -22
  14. package/dist/application/Doctor.js +30 -1
  15. package/dist/application/EvidenceBundleFiles.js +16 -101
  16. package/dist/application/EvidenceLedger.js +3 -2
  17. package/dist/application/InvestigationProviders.js +48 -0
  18. package/dist/application/InvestigationWorkspaceStore.js +214 -0
  19. package/dist/application/LinuxHopper.js +72 -48
  20. package/dist/application/ProcessCaptureAuthority.js +25 -0
  21. package/dist/application/ProcessCaptureCapability.js +23 -0
  22. package/dist/application/ProcessCaptureError.js +17 -0
  23. package/dist/application/ProcessCaptureLifecycle.js +287 -0
  24. package/dist/application/ProcessCheckpoints.js +129 -0
  25. package/dist/application/ProcessCli.js +128 -0
  26. package/dist/application/ProcessEvidence.js +38 -0
  27. package/dist/application/ProcessHarness.js +252 -272
  28. package/dist/application/ProcessNormalization.js +10 -1
  29. package/dist/application/ProcessOwnership.js +39 -1
  30. package/dist/application/ProcessSampling.js +39 -23
  31. package/dist/application/ReferenceSourceImportEntries.js +18 -3
  32. package/dist/application/ReferenceSourceImportTypes.js +32 -0
  33. package/dist/application/Setup.js +44 -96
  34. package/dist/application/SetupClients.js +19 -0
  35. package/dist/application/SetupInstallFailure.js +53 -0
  36. package/dist/application/SetupPlan.js +31 -0
  37. package/dist/application/SetupSkill.js +44 -0
  38. package/dist/application/TerminalRenderer.js +92 -0
  39. package/dist/application/Uninstall.js +33 -23
  40. package/dist/application/UnknownEvidence.js +33 -0
  41. package/dist/application/Upgrade.js +1 -1
  42. package/dist/application/runtime.js +2 -2
  43. package/dist/artifacts/ArtifactProvider.js +20 -11
  44. package/dist/artifacts/ArtifactReader.js +3 -1
  45. package/dist/artifacts/AsarArtifactReader.js +8 -1
  46. package/dist/artifacts/DirectoryArtifactReader.js +1 -0
  47. package/dist/artifacts/MachOSliceArtifactReader.js +1 -0
  48. package/dist/artifacts/NativeDmgArtifactReader.js +151 -0
  49. package/dist/artifacts/ZipArtifactReader.js +1 -0
  50. package/dist/cli.js +57 -32
  51. package/dist/cliEvidenceCommands.js +11 -12
  52. package/dist/cliInvestigationCommands.js +86 -0
  53. package/dist/cliOutput.js +41 -0
  54. package/dist/cliProcessCommands.js +29 -0
  55. package/dist/config.js +41 -24
  56. package/dist/contracts/artifactToolContracts.js +1 -0
  57. package/dist/contracts/investigationExamples.js +3 -3
  58. package/dist/contracts/processCaptureExample.js +50 -7
  59. package/dist/contracts/promptContracts.js +256 -0
  60. package/dist/contracts/sessionLifecycleInputs.js +11 -0
  61. package/dist/contracts/toolContractTypes.js +1 -0
  62. package/dist/contracts/toolContracts.js +13 -8
  63. package/dist/contracts/toolOutputSchemas.js +33 -4
  64. package/dist/domain/analysisSnapshot.js +149 -0
  65. package/dist/domain/changedBehavior.js +32 -9
  66. package/dist/domain/errors.js +186 -39
  67. package/dist/domain/evidence.js +4 -2
  68. package/dist/domain/evidenceBundle.js +28 -0
  69. package/dist/domain/hopperStartupFailure.js +55 -0
  70. package/dist/domain/investigationWorkspace.js +214 -0
  71. package/dist/domain/jsonValue.js +2 -0
  72. package/dist/domain/nativeInspection.js +3 -2
  73. package/dist/domain/processCapture.js +100 -293
  74. package/dist/domain/processCaptureValidation.js +124 -0
  75. package/dist/domain/processComparison.js +177 -32
  76. package/dist/domain/processScenario.js +370 -0
  77. package/dist/domain/reconstructionVerification.js +3 -3
  78. package/dist/domain/staticRuntimeCorrelation.js +3 -2
  79. package/dist/hopper/BridgeLauncher.js +47 -20
  80. package/dist/hopper/HopperClient.js +17 -2
  81. package/dist/hopper/HopperProvider.js +1 -0
  82. package/dist/identity.js +1 -0
  83. package/dist/main.js +47 -24
  84. package/dist/server/createServer.js +2 -0
  85. package/dist/server/promptCompletion.js +144 -0
  86. package/dist/server/registerEnhancedTools.js +2 -6
  87. package/dist/server/registerEvidenceTools.js +2 -8
  88. package/dist/server/registerFunctionComparisonTool.js +1 -1
  89. package/dist/server/registerInvestigationTools.js +31 -11
  90. package/dist/server/registerOfficialTools.js +6 -13
  91. package/dist/server/registerProcessComparisonTool.js +28 -14
  92. package/dist/server/registerPrompts.js +67 -0
  93. package/dist/server/registerSessionStatusTool.js +11 -0
  94. package/dist/server/registerSessionTools.js +55 -43
  95. package/dist/server/sessionEvidence.js +2 -8
  96. package/dist/server/sessionToolPolicies.js +2 -48
  97. package/dist/server/toolResult.js +7 -3
  98. package/install.sh +11 -11
  99. package/package.json +9 -3
  100. package/scripts/hopper-demo-x11.py +349 -0
  101. package/scripts/prepare-node-pty.mjs +35 -0
  102. package/scripts/rea.mjs +6 -3
  103. package/skills/rea-analysis/SKILL.md +27 -5
@@ -0,0 +1,92 @@
1
+ import { createRequire } from "node:module";
2
+ const require = createRequire(import.meta.url);
3
+ // SAFETY: both pinned xterm packages publish CommonJS at runtime and matching declarations.
4
+ const HeadlessPackage = require("@xterm/headless");
5
+ // SAFETY: the addon package is pinned with the compatible headless xterm release.
6
+ const SerializePackage = require("@xterm/addon-serialize");
7
+ /** Owns one headless terminal and serializes writes into deterministic frames. */
8
+ /**
9
+ * Reconstructs bounded terminal states while raw PTY chunks remain authoritative.
10
+ *
11
+ * Rendered frames answer what an operator saw after control-sequence handling;
12
+ * raw frames preserve byte/chunk differences that can render identically.
13
+ * Comparisons retain both because neither representation subsumes the other.
14
+ */
15
+ export class TerminalRenderer {
16
+ options;
17
+ #terminal;
18
+ #serializeAddon = new SerializePackage.SerializeAddon();
19
+ #frames = [];
20
+ #pending = Promise.resolve();
21
+ #capturedBytes = 0;
22
+ #truncated = false;
23
+ constructor(options) {
24
+ this.options = options;
25
+ this.#terminal = new HeadlessPackage.Terminal({
26
+ allowProposedApi: true,
27
+ cols: options.columns,
28
+ rows: options.rows,
29
+ scrollback: options.scrollback,
30
+ });
31
+ this.#terminal.loadAddon(this.#serializeAddon);
32
+ }
33
+ /** Queue one PTY chunk and capture state only after xterm has parsed it. */
34
+ write(data, atMs) {
35
+ this.#pending = this.#pending.then(() => new Promise((resolveWrite) => {
36
+ this.#terminal.write(data, () => {
37
+ this.#capture(atMs);
38
+ resolveWrite();
39
+ });
40
+ }));
41
+ }
42
+ /** Queue a terminal resize after every preceding write. */
43
+ resize(columns, rows, atMs) {
44
+ this.#pending = this.#pending.then(() => {
45
+ this.#terminal.resize(columns, rows);
46
+ this.#capture(atMs);
47
+ });
48
+ }
49
+ /** Await all queued parsing and return immutable rendered observations. */
50
+ async frames() {
51
+ await this.#pending;
52
+ return this.#frames;
53
+ }
54
+ /** Whether a rendered observation exceeded its independent capture budget. */
55
+ truncated() {
56
+ return this.#truncated;
57
+ }
58
+ /** Release addon and terminal resources after all writes settle. */
59
+ async dispose() {
60
+ await this.#pending;
61
+ this.#serializeAddon.dispose();
62
+ this.#terminal.dispose();
63
+ }
64
+ #capture(atMs) {
65
+ const buffer = this.#terminal.buffer.active;
66
+ const lines = [];
67
+ for (let row = 0; row < this.#terminal.rows; row += 1) {
68
+ const line = buffer.getLine(buffer.viewportY + row);
69
+ lines.push(this.options.normalize((line?.translateToString(false, 0, this.#terminal.cols) ?? "").padEnd(this.#terminal.cols, " ")));
70
+ }
71
+ const serializedState = this.options.normalize(this.#serializeAddon.serialize());
72
+ const bytes = Buffer.byteLength(serializedState) +
73
+ lines.reduce((total, line) => total + Buffer.byteLength(line), 0);
74
+ if (this.#frames.length >= this.options.maxFrames ||
75
+ this.#capturedBytes + bytes > this.options.maxBytes) {
76
+ this.#truncated = true;
77
+ return;
78
+ }
79
+ this.#capturedBytes += bytes;
80
+ this.#frames.push({
81
+ sequence: this.#frames.length,
82
+ at_ms: atMs,
83
+ columns: this.#terminal.cols,
84
+ rows: this.#terminal.rows,
85
+ cursor_x: buffer.cursorX,
86
+ cursor_y: buffer.cursorY,
87
+ active_buffer: buffer.type,
88
+ lines,
89
+ serialized_state: serializedState,
90
+ });
91
+ }
92
+ }
@@ -6,6 +6,13 @@ import writeFileAtomic from "write-file-atomic";
6
6
  import { z } from "zod";
7
7
  import { PRODUCT_IDENTITY } from "../identity.js";
8
8
  import { supportedClients } from "./Setup.js";
9
+ const systemFileSystem = {
10
+ readText: (path) => readFile(path, "utf8"),
11
+ copy: (source, destination) => copyFile(source, destination),
12
+ writeText: (path, contents) => writeFileAtomic(path, contents, { encoding: "utf8" }),
13
+ stat: (path) => lstat(path),
14
+ remove: (path) => rm(path, { recursive: true }),
15
+ };
9
16
  /** Remove only REA-owned registrations and managed files, optionally including local state. */
10
17
  export const runUninstall = async (purgeData, host = systemUninstallHost()) => {
11
18
  const items = [];
@@ -27,26 +34,26 @@ export const runUninstall = async (purgeData, host = systemUninstallHost()) => {
27
34
  };
28
35
  };
29
36
  /** Create uninstall effects contained to detected client configs and REA-owned paths. */
30
- export const systemUninstallHost = (home = homedir()) => ({
37
+ export const systemUninstallHost = (home = homedir(), fileSystem = systemFileSystem) => ({
31
38
  clients: () => Promise.resolve(supportedClients(home)),
32
- removeClient,
33
- removeSkill: () => removeManagedPath(join(home, ".agents/skills", PRODUCT_IDENTITY.skillName), "skill"),
39
+ removeClient: (client) => removeClient(client, fileSystem),
40
+ removeSkill: () => removeManagedPath(join(home, ".agents/skills", PRODUCT_IDENTITY.skillName), "skill", fileSystem),
34
41
  purgeData: async () => [
35
- await removeManagedPath(join(home, ".rea/cache"), "cache"),
36
- await removeManagedPath(join(home, ".rea/state"), "state"),
42
+ await removeManagedPath(join(home, ".rea/cache"), "cache", fileSystem),
43
+ await removeManagedPath(join(home, ".rea/state"), "state", fileSystem),
37
44
  ],
38
45
  });
39
- const removeClient = async (client) => {
46
+ const removeClient = async (client, fileSystem) => {
40
47
  if (client.format === "unsupported")
41
48
  return item(client.name, "skipped", "This client has no documented local MCP configuration boundary.");
42
49
  let original;
43
50
  try {
44
- original = await readFile(client.configPath, "utf8");
51
+ original = await fileSystem.readText(client.configPath);
45
52
  }
46
53
  catch (cause) {
47
54
  return isMissing(cause)
48
55
  ? item(client.name, "skipped", "Configuration does not exist.")
49
- : item(client.name, "failed", "Configuration could not be read.");
56
+ : item(client.name, "failed", "Configuration could not be read. Check file permissions, then rerun uninstall.");
50
57
  }
51
58
  let document;
52
59
  let key;
@@ -57,7 +64,7 @@ const removeClient = async (client) => {
57
64
  servers = optionalObject(document[key]);
58
65
  }
59
66
  catch {
60
- return item(client.name, "failed", "Malformed configuration was preserved without mutation.");
67
+ return item(client.name, "failed", `Configuration is not valid ${client.format === "toml" ? "TOML" : "JSON"} and was not changed. Repair it, then rerun uninstall.`);
61
68
  }
62
69
  const registration = servers[PRODUCT_IDENTITY.mcpServerKey];
63
70
  if (registration === undefined)
@@ -69,28 +76,31 @@ const removeClient = async (client) => {
69
76
  document[key] = remaining;
70
77
  const backupPath = `${client.configPath}.rea.backup`;
71
78
  try {
72
- await copyFile(client.configPath, backupPath);
79
+ await fileSystem.copy(client.configPath, backupPath);
73
80
  }
74
81
  catch {
75
- return item(client.name, "failed", "Configuration backup failed; no mutation was attempted.");
82
+ return item(client.name, "failed", "Configuration could not be backed up, so no change was made. Check file permissions, then rerun uninstall.");
76
83
  }
77
84
  try {
78
85
  const encoded = client.format === "toml"
79
86
  ? stringifyToml(document)
80
87
  : `${JSON.stringify(document, null, 2)}\n`;
81
- await writeFileAtomic(client.configPath, encoded, { encoding: "utf8" });
88
+ await fileSystem.writeText(client.configPath, encoded);
82
89
  const readback = objectSchema.parse(client.format === "toml"
83
- ? parseToml(await readFile(client.configPath, "utf8"))
84
- : JSON.parse(await readFile(client.configPath, "utf8")));
90
+ ? parseToml(await fileSystem.readText(client.configPath))
91
+ : JSON.parse(await fileSystem.readText(client.configPath)));
85
92
  if (PRODUCT_IDENTITY.mcpServerKey in optionalObject(readback[key]))
86
93
  throw new Error("registration readback mismatch");
87
94
  return item(client.name, "removed", `Removed registration from ${client.configPath}.`);
88
95
  }
89
96
  catch {
90
- await writeFileAtomic(client.configPath, original, {
91
- encoding: "utf8",
92
- }).catch(() => undefined);
93
- return item(client.name, "failed", `Configuration update failed; the original was restored and ${backupPath} was retained.`);
97
+ try {
98
+ await fileSystem.writeText(client.configPath, original);
99
+ }
100
+ catch {
101
+ return item(client.name, "failed", "Configuration could not be updated or restored. Restore its `.rea.backup` manually, then rerun uninstall.");
102
+ }
103
+ return item(client.name, "failed", "Configuration could not be updated. The original was restored and its `.rea.backup` was retained. Repair the configuration or restore the backup, then rerun uninstall.");
94
104
  }
95
105
  };
96
106
  /** Ownership rule for persistent MCP registrations created by REA setup. */
@@ -107,18 +117,18 @@ const isOwnedRegistration = (value) => {
107
117
  JSON.stringify(args) ===
108
118
  JSON.stringify(["-y", PRODUCT_IDENTITY.packageName, "mcp"])));
109
119
  };
110
- const removeManagedPath = async (path, name) => {
120
+ const removeManagedPath = async (path, name, fileSystem) => {
111
121
  try {
112
- const stats = await lstat(path);
122
+ const stats = await fileSystem.stat(path);
113
123
  if (stats.isSymbolicLink())
114
- return item(name, "retained", `Refused to follow symlink ${path}.`);
115
- await rm(path, { recursive: true });
124
+ return item(name, "retained", "REA did not remove this item because its managed path is a symbolic link. Verify the link target before removing it manually.");
125
+ await fileSystem.remove(path);
116
126
  return item(name, "removed", `Removed ${path}.`);
117
127
  }
118
128
  catch (cause) {
119
129
  return isMissing(cause)
120
130
  ? item(name, "skipped", `${path} does not exist.`)
121
- : item(name, "failed", `${path} could not be removed.`);
131
+ : item(name, "failed", "This item could not be removed. Check file permissions, then rerun uninstall.");
122
132
  }
123
133
  };
124
134
  const item = (name, status, detail) => ({ name, status, detail });
@@ -0,0 +1,33 @@
1
+ import { createEvidence } from "../domain/evidence.js";
2
+ /** Provider identity for analyst-authored residual-unknown mutations. */
3
+ export const UNKNOWN_REGISTRY_PROVIDER = {
4
+ id: "rea-unknown-registry",
5
+ name: "REA residual unknown registry",
6
+ version: "1",
7
+ };
8
+ /** Create immutable evidence for one approved residual-unknown mutation. */
9
+ export const unknownMutationEvidence = (target, input) => createEvidence(target, UNKNOWN_REGISTRY_PROVIDER, {
10
+ predicateType: "rea.residual-unknown-mutation/v1",
11
+ operation: "record_unknown",
12
+ parameters: { domain: input.domain, severity: input.severity },
13
+ result: {
14
+ action: "record",
15
+ question: input.question,
16
+ required_authority: input.required_authority,
17
+ required_confidence: input.required_confidence,
18
+ },
19
+ confidence: "derived",
20
+ authority: "analyst-inference",
21
+ evidenceLinks: unknownEvidenceLinks(input),
22
+ limitations: [
23
+ "Registry mutation evidence records analyst intent, not proof of the answer.",
24
+ ],
25
+ });
26
+ /** Deduplicate evidence links carried by an unknown mutation. */
27
+ export const unknownEvidenceLinks = (input) => [
28
+ ...input.supporting_evidence_ids,
29
+ ...input.contradicting_evidence_ids,
30
+ ...("resolution" in input && input.resolution !== null
31
+ ? input.resolution.evidence_ids
32
+ : []),
33
+ ].filter((id, index, values) => values.indexOf(id) === index);
@@ -33,7 +33,7 @@ export const runUpgrade = async (currentVersion, host = systemUpgradeHost(), out
33
33
  currentVersion,
34
34
  latestVersion: latestVersion ?? null,
35
35
  reason: "install",
36
- remediation: `${UPGRADE_COMMAND} failed.`,
36
+ remediation: `REA could not update through npm. Check npm registry access and global install permissions, then run: ${UPGRADE_COMMAND}`,
37
37
  };
38
38
  return {
39
39
  status: "upgraded",
@@ -11,8 +11,8 @@ import { silentLogger } from "../logger.js";
11
11
  */
12
12
  export const createBinarySession = (config, logger = silentLogger) => {
13
13
  return new BinarySession(new CompositeProvider([
14
- new ArtifactProvider(),
14
+ new ArtifactProvider(config.artifactNativeMountEnabled),
15
15
  new NativeMacOSProvider(),
16
16
  new HopperProvider(config, logger),
17
- ]));
17
+ ]), { snapshotsEnabled: config.hopperLoaderArgs.length === 0 });
18
18
  };
@@ -5,13 +5,14 @@ import { ARTIFACT_TOOL_CONTRACTS, artifactInventoryInputSchema, artifactExtracti
5
5
  import { AnalysisCapabilityUnavailableError, ArtifactOperationError, } from "../domain/errors.js";
6
6
  import { err, ok } from "../domain/result.js";
7
7
  import { ArtifactReaderFailure, } from "./ArtifactReader.js";
8
- const IDENTITY = Object.freeze({
9
- id: "rea-artifact-graph",
10
- name: "REA safe artifact graph provider",
11
- version: "1",
12
- });
8
+ import { ARTIFACT_GRAPH_PROVIDER } from "../application/InvestigationProviders.js";
9
+ const IDENTITY = Object.freeze(ARTIFACT_GRAPH_PROVIDER);
13
10
  /** Read-only inventory and exclusively owned extraction provider. */
14
11
  export class ArtifactProvider {
12
+ nativeMountEnabled;
13
+ constructor(nativeMountEnabled = false) {
14
+ this.nativeMountEnabled = nativeMountEnabled;
15
+ }
15
16
  #capabilities = Object.freeze(ARTIFACT_TOOL_CONTRACTS.map((contract) => Object.freeze({
16
17
  provider: IDENTITY,
17
18
  operation: contract.name,
@@ -36,8 +37,8 @@ export class ArtifactProvider {
36
37
  timeoutMs: 120_000,
37
38
  }),
38
39
  limitations: Object.freeze([
39
- "DMG and PKG child inventory requires a future native read-only adapter.",
40
- "Nested containers are recorded but not recursively expanded implicitly.",
40
+ "DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.",
41
+ "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only.",
41
42
  ]),
42
43
  })));
43
44
  identity() {
@@ -47,13 +48,15 @@ export class ArtifactProvider {
47
48
  return this.#capabilities;
48
49
  }
49
50
  createClient(target) {
50
- return new ArtifactClient(target);
51
+ return new ArtifactClient(target, this.nativeMountEnabled);
51
52
  }
52
53
  }
53
54
  class ArtifactClient {
54
55
  target;
55
- constructor(target) {
56
+ nativeMountEnabled;
57
+ constructor(target, nativeMountEnabled) {
56
58
  this.target = target;
59
+ this.nativeMountEnabled = nativeMountEnabled;
57
60
  }
58
61
  async execute(operation, parameters, options) {
59
62
  if (operation === "health")
@@ -90,7 +93,13 @@ class ArtifactClient {
90
93
  occurrenceLimit: parsed.occurrence_limit,
91
94
  edgeOffset: parsed.edge_offset,
92
95
  edgeLimit: parsed.edge_limit,
93
- }, options?.signal);
96
+ }, {
97
+ ...(options?.signal === undefined ? {} : { signal: options.signal }),
98
+ nativeMount: {
99
+ nativeMountApproved: parsed.native_mount_approved === true,
100
+ nativeMountEnabled: this.nativeMountEnabled,
101
+ },
102
+ });
94
103
  return ok(createAnalysisExecution(result, IDENTITY, {
95
104
  rawResult: null,
96
105
  limitations: result.limitations,
@@ -120,7 +129,7 @@ const limitsFrom = (input) => ({
120
129
  const isArtifactOperation = (operation) => ARTIFACT_TOOL_CONTRACTS.some(({ name }) => name === operation);
121
130
  const translateFailure = (operation, cause) => {
122
131
  if (cause instanceof ArtifactReaderFailure)
123
- return new ArtifactOperationError(operation, cause.reason);
132
+ return new ArtifactOperationError(operation, cause.reason, cause.details);
124
133
  return new ArtifactOperationError(operation, "io");
125
134
  };
126
135
  const subjectFor = (path, manifest) => ({
@@ -1,9 +1,11 @@
1
1
  /** Typed adapter failure translated at provider boundary. */
2
2
  export class ArtifactReaderFailure extends Error {
3
3
  reason;
4
- constructor(reason, message, options) {
4
+ details;
5
+ constructor(reason, message, options, details) {
5
6
  super(message, options);
6
7
  this.reason = reason;
8
+ this.details = details;
7
9
  this.name = "ArtifactReaderFailure";
8
10
  }
9
11
  }
@@ -1,7 +1,13 @@
1
1
  import { Readable } from "node:stream";
2
2
  import { extractFile, listPackage, statFile } from "@electron/asar";
3
3
  import { ArtifactReaderFailure, } from "./ArtifactReader.js";
4
- /** Official Electron ASAR adapter. Individual files remain caller-bounded. */
4
+ /**
5
+ * Official Electron ASAR adapter. Individual files remain caller-bounded.
6
+ *
7
+ * An entry marked `unpacked` is metadata, not an integrity exemption: Electron
8
+ * stores its bytes beside the archive in `<archive>.unpacked`, and callers must
9
+ * hash those companion bytes against the archive's declared integrity value.
10
+ */
5
11
  export class AsarArtifactReader {
6
12
  path;
7
13
  format = "asar";
@@ -34,6 +40,7 @@ export class AsarArtifactReader {
34
40
  /^[a-f0-9]{64}$/u.test(metadata.integrity.hash)
35
41
  ? metadata.integrity.hash
36
42
  : null,
43
+ unpacked: "unpacked" in metadata && metadata.unpacked === true,
37
44
  limitations: kind === "symlink"
38
45
  ? ["ASAR symlink target was not followed or disclosed."]
39
46
  : [
@@ -55,6 +55,7 @@ export class DirectoryArtifactReader {
55
55
  encrypted: false,
56
56
  byteOffset: null,
57
57
  declaredSha256: null,
58
+ unpacked: false,
58
59
  limitations: kind === "symlink"
59
60
  ? ["Symlink target was not followed or disclosed."]
60
61
  : [],
@@ -40,6 +40,7 @@ export class MachOSliceArtifactReader {
40
40
  encrypted: false,
41
41
  byteOffset: architecture.file_offset,
42
42
  declaredSha256: null,
43
+ unpacked: false,
43
44
  limitations: [],
44
45
  adapterKey: `${String(architecture.file_offset)}:${String(architecture.size)}`,
45
46
  };
@@ -0,0 +1,151 @@
1
+ import { execFile } from "node:child_process";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { basename, join } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { parse } from "plist";
7
+ import { z } from "zod";
8
+ import { ArtifactReaderFailure, } from "./ArtifactReader.js";
9
+ import { DirectoryArtifactReader } from "./DirectoryArtifactReader.js";
10
+ const execFileAsync = promisify(execFile);
11
+ const attachOutputSchema = z.object({
12
+ "system-entities": z.array(z.object({
13
+ "dev-entry": z.string().startsWith("/dev/"),
14
+ "mount-point": z.string().optional(),
15
+ })),
16
+ });
17
+ const systemHost = {
18
+ async run(arguments_, signal) {
19
+ try {
20
+ const { stdout } = await execFileAsync("/usr/bin/hdiutil", [...arguments_], {
21
+ encoding: "utf8",
22
+ timeout: 120_000,
23
+ ...(signal === undefined ? {} : { signal }),
24
+ });
25
+ return { stdout, exitCode: 0 };
26
+ }
27
+ catch (cause) {
28
+ if (cause instanceof Error && cause.name === "AbortError")
29
+ throw new ArtifactReaderFailure("cancelled", "DMG operation cancelled");
30
+ throw new ArtifactReaderFailure("format", "hdiutil rejected the DMG", {
31
+ cause,
32
+ });
33
+ }
34
+ },
35
+ };
36
+ /** Read-only macOS DMG adapter that owns attachment and reverse-order detach. */
37
+ export class NativeDmgArtifactReader {
38
+ path;
39
+ host;
40
+ format = "file";
41
+ #provenance = [];
42
+ #directory;
43
+ #devices = [];
44
+ #mountRoot;
45
+ constructor(path, host) {
46
+ this.path = path;
47
+ this.host = host;
48
+ }
49
+ /** Verify and attach one image beneath an exclusively owned temporary root. */
50
+ static async create(path, signal, host = systemHost) {
51
+ if (process.platform !== "darwin")
52
+ throw new ArtifactReaderFailure("unavailable", "Native DMG traversal is available only on macOS");
53
+ const reader = new NativeDmgArtifactReader(path, host);
54
+ await reader.attach(signal);
55
+ return reader;
56
+ }
57
+ async *entries(signal) {
58
+ if (this.#directory === undefined)
59
+ throw new ArtifactReaderFailure("unavailable", "DMG is not attached");
60
+ const prefix = basename(this.path);
61
+ for await (const entry of this.#directory.entries(signal))
62
+ yield { ...entry, path: `${prefix}/${entry.path}` };
63
+ }
64
+ open(entry, signal) {
65
+ if (this.#directory === undefined)
66
+ return Promise.reject(new ArtifactReaderFailure("unavailable", "DMG is not attached"));
67
+ return this.#directory.open(entry, signal);
68
+ }
69
+ provenance() {
70
+ return this.#provenance;
71
+ }
72
+ async close() {
73
+ let detachFailure;
74
+ for (const device of [...this.#devices].reverse()) {
75
+ try {
76
+ await runChecked(this.host, ["detach", device]);
77
+ this.#provenance.push(command(["detach", device], ["mount"]));
78
+ }
79
+ catch (cause) {
80
+ detachFailure ??= cause;
81
+ }
82
+ }
83
+ this.#devices = [];
84
+ if (this.#mountRoot !== undefined)
85
+ await rm(this.#mountRoot, { recursive: true, force: true }).catch((cause) => {
86
+ detachFailure ??= cause;
87
+ });
88
+ if (detachFailure !== undefined)
89
+ throw new ArtifactReaderFailure("unavailable", "DMG detach or mount-root cleanup failed", { cause: detachFailure });
90
+ }
91
+ async attach(signal) {
92
+ await runChecked(this.host, ["verify", this.path], signal);
93
+ this.#provenance.push(command(["verify", this.path], ["read"]));
94
+ this.#mountRoot = await mkdtemp(join(tmpdir(), "rea-dmg-"));
95
+ try {
96
+ const attached = await runChecked(this.host, [
97
+ "attach",
98
+ "-readonly",
99
+ "-nobrowse",
100
+ "-plist",
101
+ "-mountroot",
102
+ this.#mountRoot,
103
+ this.path,
104
+ ], signal);
105
+ const parsed = attachOutputSchema.parse(parse(attached.stdout));
106
+ this.#devices = parsed["system-entities"].map((entity) => entity["dev-entry"]);
107
+ if (this.#devices.length === 0)
108
+ throw new ArtifactReaderFailure("format", "hdiutil returned no attached devices");
109
+ for (const entity of parsed["system-entities"])
110
+ if (entity["mount-point"] !== undefined &&
111
+ !entity["mount-point"].startsWith(`${this.#mountRoot}/`))
112
+ throw new ArtifactReaderFailure("path", "hdiutil mounted outside the owned root");
113
+ this.#provenance.push(command([
114
+ "attach",
115
+ "-readonly",
116
+ "-nobrowse",
117
+ "-plist",
118
+ "-mountroot",
119
+ this.#mountRoot,
120
+ this.path,
121
+ ], ["read", "mount"]));
122
+ this.#directory = new DirectoryArtifactReader(this.#mountRoot);
123
+ }
124
+ catch (cause) {
125
+ let cleanupFailure;
126
+ try {
127
+ await this.close();
128
+ }
129
+ catch (cleanupCause) {
130
+ cleanupFailure = cleanupCause;
131
+ }
132
+ if (cleanupFailure !== undefined)
133
+ throw new ArtifactReaderFailure("unavailable", "DMG attach failed and cleanup could not detach every device", { cause: new AggregateError([cause, cleanupFailure]) });
134
+ throw cause;
135
+ }
136
+ }
137
+ }
138
+ const runChecked = async (host, arguments_, signal) => {
139
+ const result = await host.run(arguments_, signal);
140
+ if (result.exitCode !== 0)
141
+ throw new ArtifactReaderFailure("unavailable", `hdiutil ${arguments_[0] ?? "operation"} failed with a non-zero exit code`);
142
+ return { stdout: result.stdout, exitCode: 0 };
143
+ };
144
+ const command = (arguments_, effects) => ({
145
+ tool: "/usr/bin/hdiutil",
146
+ arguments: [...arguments_],
147
+ tool_version: null,
148
+ executable_sha256: null,
149
+ exit_code: 0,
150
+ effects,
151
+ });
@@ -55,6 +55,7 @@ export class ZipArtifactReader {
55
55
  encrypted: entry.encrypted,
56
56
  byteOffset: null,
57
57
  declaredSha256: null,
58
+ unpacked: false,
58
59
  limitations: symlink ? ["Archive symlink target was not read."] : [],
59
60
  adapterKey: entry.filename,
60
61
  };