rea-agents 1.1.0 → 1.2.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 (48) hide show
  1. package/README.md +21 -2
  2. package/dist/application/ArtifactInventory.js +80 -44
  3. package/dist/application/CommandShimReplay.js +136 -0
  4. package/dist/application/EvidenceBundleFiles.js +9 -1
  5. package/dist/application/ProcessCaptureAuthority.js +25 -0
  6. package/dist/application/ProcessCaptureCapability.js +23 -0
  7. package/dist/application/ProcessCaptureError.js +5 -0
  8. package/dist/application/ProcessCaptureLifecycle.js +269 -0
  9. package/dist/application/ProcessCheckpoints.js +129 -0
  10. package/dist/application/ProcessCli.js +61 -0
  11. package/dist/application/ProcessEvidence.js +38 -0
  12. package/dist/application/ProcessHarness.js +252 -272
  13. package/dist/application/ProcessNormalization.js +10 -1
  14. package/dist/application/ProcessOwnership.js +39 -1
  15. package/dist/application/ProcessSampling.js +39 -23
  16. package/dist/application/Setup.js +51 -76
  17. package/dist/application/SetupSkill.js +44 -0
  18. package/dist/application/TerminalRenderer.js +92 -0
  19. package/dist/application/runtime.js +1 -1
  20. package/dist/artifacts/ArtifactProvider.js +18 -6
  21. package/dist/artifacts/ArtifactReader.js +3 -1
  22. package/dist/artifacts/AsarArtifactReader.js +8 -1
  23. package/dist/artifacts/DirectoryArtifactReader.js +1 -0
  24. package/dist/artifacts/MachOSliceArtifactReader.js +1 -0
  25. package/dist/artifacts/NativeDmgArtifactReader.js +151 -0
  26. package/dist/artifacts/ZipArtifactReader.js +1 -0
  27. package/dist/cli.js +29 -24
  28. package/dist/cliProcessCommands.js +19 -0
  29. package/dist/config.js +2 -0
  30. package/dist/contracts/artifactToolContracts.js +1 -0
  31. package/dist/contracts/investigationExamples.js +3 -3
  32. package/dist/contracts/processCaptureExample.js +50 -7
  33. package/dist/contracts/toolContracts.js +3 -2
  34. package/dist/domain/changedBehavior.js +2 -2
  35. package/dist/domain/errors.js +11 -3
  36. package/dist/domain/processCapture.js +99 -293
  37. package/dist/domain/processCaptureValidation.js +124 -0
  38. package/dist/domain/processComparison.js +176 -32
  39. package/dist/domain/processScenario.js +370 -0
  40. package/dist/domain/reconstructionVerification.js +3 -3
  41. package/dist/domain/staticRuntimeCorrelation.js +3 -2
  42. package/dist/identity.js +1 -0
  43. package/dist/server/registerProcessComparisonTool.js +28 -8
  44. package/dist/server/registerSessionTools.js +3 -25
  45. package/dist/server/sessionToolPolicies.js +1 -6
  46. package/package.json +5 -1
  47. package/scripts/prepare-node-pty.mjs +35 -0
  48. package/skills/rea-analysis/SKILL.md +19 -4
@@ -43,7 +43,13 @@ const systemHost = {
43
43
  process.kill(-processGroupId, signal);
44
44
  },
45
45
  };
46
- /** Verify run-token ownership before signaling a POSIX process group. */
46
+ /**
47
+ * Verify run-token ownership before signaling a POSIX process group.
48
+ *
49
+ * Process IDs and group IDs can be reused. REA therefore re-reads every live
50
+ * member's environment immediately before signaling and fails closed if any
51
+ * member cannot be inspected or lacks the per-capture run token.
52
+ */
47
53
  export const cleanupOwnedProcessGroup = async (ownership, host = systemHost) => {
48
54
  let members;
49
55
  try {
@@ -97,6 +103,38 @@ export const cleanupOwnedProcessGroup = async (ownership, host = systemHost) =>
97
103
  }
98
104
  return { cleaned: true, signaled: true };
99
105
  };
106
+ /** Observe one group without signaling it, failing closed on identity doubt. */
107
+ export const observeOwnedProcessGroup = async (ownership, host = systemHost) => {
108
+ let members;
109
+ try {
110
+ members = await host.listMembers(ownership.processGroupId);
111
+ }
112
+ catch {
113
+ return {
114
+ state: "unverifiable",
115
+ reason: "process group could not be inspected",
116
+ };
117
+ }
118
+ if (members.length === 0)
119
+ return { state: "empty" };
120
+ for (const member of members) {
121
+ try {
122
+ if ((await host.environment(member.pid)).REA_PROCESS_RUN_ID !==
123
+ ownership.runId)
124
+ return {
125
+ state: "unverifiable",
126
+ reason: "process ownership did not match",
127
+ };
128
+ }
129
+ catch {
130
+ return {
131
+ state: "unverifiable",
132
+ reason: "process ownership could not be revalidated",
133
+ };
134
+ }
135
+ }
136
+ return { state: "alive" };
137
+ };
100
138
  const commandMatches = (actual, expected) => {
101
139
  const normalizedActual = actual.trim();
102
140
  const normalizedExpected = expected.trim();
@@ -24,17 +24,25 @@ const parseProcStat = (identifier, stat) => {
24
24
  .split(/\s+/);
25
25
  const pid = Number(identifier);
26
26
  const parentPid = Number(fields[1]);
27
+ const processGroupId = Number(fields[2]);
28
+ const sessionId = Number(fields[3]);
27
29
  const startTime = fields[19];
28
30
  if (!Number.isSafeInteger(pid) ||
29
31
  pid <= 0 ||
30
32
  !Number.isSafeInteger(parentPid) ||
31
33
  parentPid < 0 ||
34
+ !Number.isSafeInteger(processGroupId) ||
35
+ processGroupId <= 0 ||
36
+ !Number.isSafeInteger(sessionId) ||
37
+ sessionId <= 0 ||
32
38
  startTime === undefined ||
33
39
  !/^\d+$/.test(startTime))
34
40
  return undefined;
35
41
  return {
36
42
  pid,
37
43
  parent_pid: parentPid,
44
+ process_group_id: processGroupId,
45
+ session_id: sessionId,
38
46
  command: "",
39
47
  startTime,
40
48
  };
@@ -104,13 +112,15 @@ const inspectProcess = async (pid, expectedParent, signal, identities) => {
104
112
  return {
105
113
  pid: before.pid,
106
114
  parent_pid: before.parent_pid,
115
+ process_group_id: before.process_group_id,
116
+ session_id: before.session_id,
107
117
  startTime: before.startTime,
108
118
  command,
109
119
  children: children ?? [],
110
120
  };
111
121
  };
112
122
  const sampleLinux = async (context) => {
113
- const { rootPid, limit, signal, sampledPids, identities } = context;
123
+ const { rootPid, limit, signal, identities } = context;
114
124
  if (limit <= 0)
115
125
  return [];
116
126
  const rootStat = await readProcessStat(rootPid, signal);
@@ -131,11 +141,9 @@ const sampleLinux = async (context) => {
131
141
  for (const node of batchResults) {
132
142
  if (node === undefined)
133
143
  continue;
134
- if (!sampledPids.has(node.pid)) {
135
- if (rows.length >= limit)
136
- break;
137
- rows.push(node);
138
- }
144
+ if (rows.length >= limit)
145
+ break;
146
+ rows.push(node);
139
147
  if (rows.length >= limit)
140
148
  break;
141
149
  for (const child of node.children) {
@@ -152,15 +160,17 @@ const sampleLinux = async (context) => {
152
160
  return rows;
153
161
  };
154
162
  const readPsRows = async (signal) => {
155
- const { stdout } = await execFileAsync("ps", ["-axo", "pid=,ppid=,command="], { signal });
163
+ const { stdout } = await execFileAsync("ps", ["-axo", "pid=,ppid=,pgid=,sess=,command="], { signal });
156
164
  return stdout
157
165
  .split("\n")
158
- .map((line) => /\s*(\d+)\s+(\d+)\s+(.*)/u.exec(line))
166
+ .map((line) => /\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(.*)/u.exec(line))
159
167
  .filter((match) => match !== null)
160
168
  .map((match) => ({
161
169
  pid: Number(match[1]),
162
170
  parent_pid: Number(match[2]),
163
- command: match[3] ?? "",
171
+ process_group_id: Number(match[3]),
172
+ session_id: Number(match[4]),
173
+ command: match[5] ?? "",
164
174
  startTime: undefined,
165
175
  }))
166
176
  .filter((row) => Number.isSafeInteger(row.pid) &&
@@ -170,7 +180,7 @@ const readPsRows = async (signal) => {
170
180
  .sort((left, right) => left.pid - right.pid);
171
181
  };
172
182
  const samplePs = async (context) => {
173
- const { rootPid, limit, signal, sampledPids } = context;
183
+ const { rootPid, limit, signal } = context;
174
184
  if (limit <= 0)
175
185
  return [];
176
186
  const rows = await readPsRows(signal);
@@ -194,9 +204,8 @@ const samplePs = async (context) => {
194
204
  continue;
195
205
  visited.add(pid);
196
206
  const row = rowByPid.get(pid);
197
- if (row !== undefined && !sampledPids.has(row.pid)) {
207
+ if (row !== undefined)
198
208
  result.push(row);
199
- }
200
209
  if (result.length >= limit)
201
210
  break;
202
211
  for (const child of childrenByParent.get(pid) ?? []) {
@@ -212,11 +221,9 @@ const sampleProcesses = async (context) => {
212
221
  const rows = process.platform === "linux"
213
222
  ? await sampleLinux(context)
214
223
  : await samplePs(context);
215
- const { elapsedMs, sampledPids, identities } = context;
224
+ const { elapsedMs, identities } = context;
216
225
  const samples = [];
217
226
  for (const row of rows) {
218
- if (sampledPids.has(row.pid))
219
- continue;
220
227
  if (row.startTime !== undefined) {
221
228
  const existing = identities.get(row.pid);
222
229
  if (existing !== undefined && existing !== row.startTime)
@@ -228,6 +235,8 @@ const sampleProcesses = async (context) => {
228
235
  pid: row.pid,
229
236
  parent_pid: row.parent_pid,
230
237
  command: row.command,
238
+ process_group_id: row.process_group_id,
239
+ session_id: row.session_id,
231
240
  });
232
241
  }
233
242
  return samples;
@@ -235,31 +244,38 @@ const sampleProcesses = async (context) => {
235
244
  /** Start bounded process-tree sampling and expose an awaited stop. */
236
245
  export const startProcessSampler = (rootPid, started, limit, samples) => {
237
246
  const identities = new Map();
238
- const sampledPids = new Set(samples.map(({ pid }) => pid));
247
+ const lastObservations = new Map();
239
248
  let pending;
240
249
  let stopped = false;
241
250
  let abortCurrent;
242
251
  let partial = false;
243
252
  const sample = () => {
244
- if (stopped || pending !== undefined || sampledPids.size >= limit)
253
+ if (stopped || pending !== undefined)
245
254
  return;
246
255
  const controller = new AbortController();
247
256
  abortCurrent = () => controller.abort();
248
257
  pending = sampleProcesses({
249
258
  rootPid,
250
259
  elapsedMs: Date.now() - started,
251
- limit: limit - sampledPids.size,
252
- sampledPids,
260
+ limit: limit + 1,
253
261
  identities,
254
262
  signal: controller.signal,
255
263
  })
256
264
  .then((values) => {
257
265
  for (const value of values) {
258
- if (sampledPids.size >= limit)
259
- break;
260
- if (sampledPids.has(value.pid))
266
+ const observation = JSON.stringify({
267
+ parent_pid: value.parent_pid,
268
+ command: value.command,
269
+ process_group_id: value.process_group_id,
270
+ session_id: value.session_id,
271
+ });
272
+ if (lastObservations.get(value.pid) === observation)
273
+ continue;
274
+ if (samples.length >= limit) {
275
+ partial = true;
261
276
  continue;
262
- sampledPids.add(value.pid);
277
+ }
278
+ lastObservations.set(value.pid, observation);
263
279
  samples.push(value);
264
280
  }
265
281
  })
@@ -9,9 +9,50 @@ import { supportsNodeVersion } from "../domain/runtimeVersion.js";
9
9
  import { runDoctor, systemDoctorHost } from "./Doctor.js";
10
10
  import { installLinuxHopper, readLinuxDistribution, } from "./LinuxHopper.js";
11
11
  import { installMacHopper } from "./MacHopper.js";
12
+ import { installCanonicalSkill } from "./SetupSkill.js";
13
+ export { installCanonicalSkill } from "./SetupSkill.js";
12
14
  const registrationCommand = () => process.env.npm_command === "exec"
13
15
  ? ["npx", "-y", PRODUCT_IDENTITY.packageName, "mcp"]
14
16
  : [resolve(process.argv[1] ?? PRODUCT_IDENTITY.cliBinary), "mcp"];
17
+ const setupPlan = (platform, hopperPath, clients) => [
18
+ ...(hopperPath === undefined
19
+ ? [
20
+ {
21
+ kind: "install_hopper",
22
+ target: platform === "darwin"
23
+ ? "~/Applications/Hopper Disassembler.app"
24
+ : "system package manager",
25
+ detail: "Download the official Hopper package, verify it, install it, and open Hopper for activation.",
26
+ external: true,
27
+ },
28
+ ]
29
+ : []),
30
+ ...clients
31
+ .filter(({ format }) => format !== "unsupported")
32
+ .map((client) => ({
33
+ kind: "configure_client",
34
+ target: client.configPath,
35
+ detail: `Add the REA MCP registration for ${client.name}; preserve unrelated configuration.`,
36
+ external: false,
37
+ })),
38
+ {
39
+ kind: "install_skill",
40
+ target: "~/.agents/skills/rea-analysis/SKILL.md",
41
+ detail: "Install or update the bundled REA analysis skill.",
42
+ external: false,
43
+ },
44
+ ];
45
+ const configureDetectedClients = async (options) => {
46
+ for (const client of options.detectedClients) {
47
+ const result = await options.host.configureClient(client, options.hopperPath, registrationCommand());
48
+ options.clients[client.name] = result;
49
+ if (result.status === "failed")
50
+ return `${client.name} configuration ${result.reason} verification failed; no successful configuration was reported.`;
51
+ if (result.status === "configured")
52
+ options.appliedActions.push(`configured_${client.name}`);
53
+ }
54
+ return undefined;
55
+ };
15
56
  /**
16
57
  * Install prerequisites and configure detected clients idempotently.
17
58
  * Discovery always precedes mutation. Interactive confirmation or explicit
@@ -34,34 +75,7 @@ export const runSetup = async (options, host = systemSetupHost(), confirm) => {
34
75
  return fail(unsupported);
35
76
  let hopperPath = await host.hopperPath();
36
77
  const detectedClients = await host.detectedClients();
37
- plannedActions = [
38
- ...(hopperPath === undefined
39
- ? [
40
- {
41
- kind: "install_hopper",
42
- target: host.platform === "darwin"
43
- ? "~/Applications/Hopper Disassembler.app"
44
- : "system package manager",
45
- detail: "Download the official Hopper package, verify it, install it, and open Hopper for activation.",
46
- external: true,
47
- },
48
- ]
49
- : []),
50
- ...detectedClients
51
- .filter(({ format }) => format !== "unsupported")
52
- .map((client) => ({
53
- kind: "configure_client",
54
- target: client.configPath,
55
- detail: `Add the REA MCP registration for ${client.name}; preserve unrelated configuration.`,
56
- external: false,
57
- })),
58
- {
59
- kind: "install_skill",
60
- target: "~/.agents/skills/rea-analysis/SKILL.md",
61
- detail: "Install or update the bundled REA analysis skill.",
62
- external: false,
63
- },
64
- ];
78
+ plannedActions = setupPlan(host.platform, hopperPath, detectedClients);
65
79
  let approved = options.approved;
66
80
  let interactiveApproval = false;
67
81
  if (!approved && confirm !== undefined && !options.structured) {
@@ -91,14 +105,15 @@ export const runSetup = async (options, host = systemSetupHost(), confirm) => {
91
105
  };
92
106
  appliedActions.push("installed_hopper");
93
107
  }
94
- for (const client of detectedClients) {
95
- const result = await host.configureClient(client, hopperPath, registrationCommand());
96
- clients[client.name] = result;
97
- if (result.status === "failed")
98
- return fail(`${client.name} configuration ${result.reason} verification failed; no successful configuration was reported.`);
99
- if (result.status === "configured")
100
- appliedActions.push(`configured_${client.name}`);
101
- }
108
+ const clientFailure = await configureDetectedClients({
109
+ host,
110
+ detectedClients,
111
+ hopperPath,
112
+ clients,
113
+ appliedActions,
114
+ });
115
+ if (clientFailure !== undefined)
116
+ return fail(clientFailure);
102
117
  const skill = await host.installSkill();
103
118
  if (skill === "failed")
104
119
  return fail("Agent skill installation or readback failed.");
@@ -353,46 +368,6 @@ export const configureTomlClient = async (client, hopperPath, command = [
353
368
  ...(backupPath === undefined ? {} : { backupPath }),
354
369
  };
355
370
  };
356
- /** Transactionally install or upgrade the versioned canonical REA skill. */
357
- export const installCanonicalSkill = async (home) => {
358
- const destination = join(home, ".agents/skills", PRODUCT_IDENTITY.skillName, "SKILL.md");
359
- const backup = `${destination}.rea.backup`;
360
- let original;
361
- try {
362
- const content = await readFile(new URL(`../../skills/${PRODUCT_IDENTITY.skillName}/SKILL.md`, import.meta.url), "utf8");
363
- original = await readFile(destination, "utf8").catch(() => undefined);
364
- if (original === content)
365
- return "unchanged";
366
- await mkdir(dirname(destination), { recursive: true });
367
- if (original !== undefined)
368
- await writeFileAtomic(backup, original, {
369
- encoding: "utf8",
370
- mode: 0o600,
371
- });
372
- await writeFileAtomic(destination, content, {
373
- encoding: "utf8",
374
- mode: 0o600,
375
- });
376
- if ((await readFile(destination, "utf8")) !== content)
377
- throw new Error("skill readback mismatch");
378
- return "installed";
379
- }
380
- catch {
381
- try {
382
- if (original === undefined)
383
- await rm(destination, { force: true });
384
- else
385
- await writeFileAtomic(destination, original, {
386
- encoding: "utf8",
387
- mode: 0o600,
388
- });
389
- }
390
- catch {
391
- // The backup remains beside the skill for explicit operator recovery.
392
- }
393
- return "failed";
394
- }
395
- };
396
371
  const major = (version) => Number.parseInt(version.split(".")[0] ?? "0", 10);
397
372
  const exists = async (path) => {
398
373
  try {
@@ -0,0 +1,44 @@
1
+ import { mkdir, readFile, rm } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import writeFileAtomic from "write-file-atomic";
4
+ import { PRODUCT_IDENTITY } from "../identity.js";
5
+ /** Transactionally install or upgrade the versioned canonical REA skill. */
6
+ export const installCanonicalSkill = async (home) => {
7
+ const destination = join(home, ".agents/skills", PRODUCT_IDENTITY.skillName, "SKILL.md");
8
+ const backup = `${destination}.rea.backup`;
9
+ let original;
10
+ try {
11
+ const content = await readFile(new URL(`../../skills/${PRODUCT_IDENTITY.skillName}/SKILL.md`, import.meta.url), "utf8");
12
+ original = await readFile(destination, "utf8").catch(() => undefined);
13
+ if (original === content)
14
+ return "unchanged";
15
+ await mkdir(dirname(destination), { recursive: true });
16
+ if (original !== undefined)
17
+ await writeFileAtomic(backup, original, {
18
+ encoding: "utf8",
19
+ mode: 0o600,
20
+ });
21
+ await writeFileAtomic(destination, content, {
22
+ encoding: "utf8",
23
+ mode: 0o600,
24
+ });
25
+ if ((await readFile(destination, "utf8")) !== content)
26
+ throw new Error("skill readback mismatch");
27
+ return "installed";
28
+ }
29
+ catch {
30
+ try {
31
+ if (original === undefined)
32
+ await rm(destination, { force: true });
33
+ else
34
+ await writeFileAtomic(destination, original, {
35
+ encoding: "utf8",
36
+ mode: 0o600,
37
+ });
38
+ }
39
+ catch {
40
+ // The backup remains beside the skill for explicit operator recovery.
41
+ }
42
+ return "failed";
43
+ }
44
+ };
@@ -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
+ }
@@ -11,7 +11,7 @@ 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
  ]));
@@ -12,6 +12,10 @@ const IDENTITY = Object.freeze({
12
12
  });
13
13
  /** Read-only inventory and exclusively owned extraction provider. */
14
14
  export class ArtifactProvider {
15
+ nativeMountEnabled;
16
+ constructor(nativeMountEnabled = false) {
17
+ this.nativeMountEnabled = nativeMountEnabled;
18
+ }
15
19
  #capabilities = Object.freeze(ARTIFACT_TOOL_CONTRACTS.map((contract) => Object.freeze({
16
20
  provider: IDENTITY,
17
21
  operation: contract.name,
@@ -36,8 +40,8 @@ export class ArtifactProvider {
36
40
  timeoutMs: 120_000,
37
41
  }),
38
42
  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.",
43
+ "DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.",
44
+ "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only.",
41
45
  ]),
42
46
  })));
43
47
  identity() {
@@ -47,13 +51,15 @@ export class ArtifactProvider {
47
51
  return this.#capabilities;
48
52
  }
49
53
  createClient(target) {
50
- return new ArtifactClient(target);
54
+ return new ArtifactClient(target, this.nativeMountEnabled);
51
55
  }
52
56
  }
53
57
  class ArtifactClient {
54
58
  target;
55
- constructor(target) {
59
+ nativeMountEnabled;
60
+ constructor(target, nativeMountEnabled) {
56
61
  this.target = target;
62
+ this.nativeMountEnabled = nativeMountEnabled;
57
63
  }
58
64
  async execute(operation, parameters, options) {
59
65
  if (operation === "health")
@@ -90,7 +96,13 @@ class ArtifactClient {
90
96
  occurrenceLimit: parsed.occurrence_limit,
91
97
  edgeOffset: parsed.edge_offset,
92
98
  edgeLimit: parsed.edge_limit,
93
- }, options?.signal);
99
+ }, {
100
+ ...(options?.signal === undefined ? {} : { signal: options.signal }),
101
+ nativeMount: {
102
+ nativeMountApproved: parsed.native_mount_approved === true,
103
+ nativeMountEnabled: this.nativeMountEnabled,
104
+ },
105
+ });
94
106
  return ok(createAnalysisExecution(result, IDENTITY, {
95
107
  rawResult: null,
96
108
  limitations: result.limitations,
@@ -120,7 +132,7 @@ const limitsFrom = (input) => ({
120
132
  const isArtifactOperation = (operation) => ARTIFACT_TOOL_CONTRACTS.some(({ name }) => name === operation);
121
133
  const translateFailure = (operation, cause) => {
122
134
  if (cause instanceof ArtifactReaderFailure)
123
- return new ArtifactOperationError(operation, cause.reason);
135
+ return new ArtifactOperationError(operation, cause.reason, cause.details);
124
136
  return new ArtifactOperationError(operation, "io");
125
137
  };
126
138
  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
  };