rea-agents 1.4.0 → 1.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 (72) hide show
  1. package/README.md +34 -12
  2. package/dist/application/AnalysisSnapshotCache.js +8 -6
  3. package/dist/application/ArtifactGraphConstruction.js +11 -2
  4. package/dist/application/ArtifactInventory.js +3 -2
  5. package/dist/application/AuthorizedArtifactInventory.js +2 -1
  6. package/dist/application/BinarySession.js +12 -7
  7. package/dist/application/BoundedJsonFiles.js +6 -29
  8. package/dist/application/BrowserEvidence.js +35 -0
  9. package/dist/application/BrowserObservationPort.js +1 -0
  10. package/dist/application/BrowserObservationService.js +52 -0
  11. package/dist/application/CapabilityInventory.js +7 -0
  12. package/dist/application/ConfiguredRoots.js +19 -0
  13. package/dist/application/CrossVersionInvestigation.js +6 -0
  14. package/dist/application/DeferredFileAuthorization.js +61 -0
  15. package/dist/application/DirectAnalysis.js +38 -18
  16. package/dist/application/EvidenceLedger.js +6 -5
  17. package/dist/application/InvestigationWorkspaceStore.js +2 -26
  18. package/dist/application/ProcessCaptureAuthority.js +3 -2
  19. package/dist/application/ProcessCaptureLifecycle.js +18 -8
  20. package/dist/application/ProcessCheckpoints.js +1 -1
  21. package/dist/application/ProcessCli.js +17 -5
  22. package/dist/application/ProcessHarness.js +6 -19
  23. package/dist/application/ProcessNormalization.js +17 -2
  24. package/dist/application/ProcessOwnership.js +4 -3
  25. package/dist/application/ProcessSampling.js +52 -17
  26. package/dist/application/ProjectPermissionStore.js +106 -3
  27. package/dist/application/ReferenceSourceImportPolicy.js +3 -2
  28. package/dist/application/TerminalRenderer.js +1 -1
  29. package/dist/artifacts/MachOSliceArtifactReader.js +1 -1
  30. package/dist/artifacts/NativeDmgArtifactReader.js +1 -1
  31. package/dist/artifacts/SafeOutputTree.js +3 -3
  32. package/dist/browser/CdpBrowserProvider.js +158 -0
  33. package/dist/browser/CdpCaptureDocuments.js +140 -0
  34. package/dist/browser/CdpCaptureEvents.js +248 -0
  35. package/dist/browser/CdpCaptureStorage.js +53 -0
  36. package/dist/browser/CdpCaptureValues.js +70 -0
  37. package/dist/browser/CdpConnection.js +218 -0
  38. package/dist/browser/CdpEndpoint.js +114 -0
  39. package/dist/browser/CdpOptionalCommand.js +15 -0
  40. package/dist/browser/CdpPageCapture.js +273 -0
  41. package/dist/catalogIdentity.js +2 -0
  42. package/dist/cli.js +2 -0
  43. package/dist/cliBrowserCommands.js +136 -0
  44. package/dist/cliInvestigationCommands.js +19 -34
  45. package/dist/cliPolicyCommands.js +13 -10
  46. package/dist/config.js +44 -0
  47. package/dist/contracts/browserToolContracts.js +80 -0
  48. package/dist/contracts/errorSchemas.js +2 -0
  49. package/dist/contracts/toolContracts.js +3 -5
  50. package/dist/domain/browserObservation.js +361 -0
  51. package/dist/domain/changedBehavior.js +4 -15
  52. package/dist/domain/errors.js +41 -0
  53. package/dist/domain/jsonLimits.js +26 -0
  54. package/dist/domain/permissionPolicy.js +6 -2
  55. package/dist/domain/processCapture.js +3 -1
  56. package/dist/domain/processComparison.js +22 -34
  57. package/dist/domain/reconstructionVerification.js +2 -15
  58. package/dist/domain/reconstructionVerificationSchemas.js +2 -0
  59. package/dist/domain/staticRuntimeCorrelation.js +2 -0
  60. package/dist/domain/symbolAnalysis.js +1 -1
  61. package/dist/generatedPackageMetadata.js +2 -2
  62. package/dist/main.js +6 -0
  63. package/dist/native/CommandRunner.js +11 -4
  64. package/dist/server/createServer.js +7 -0
  65. package/dist/server/registerBrowserTools.js +36 -0
  66. package/dist/server/registerInvestigationTools.js +30 -35
  67. package/dist/server/registerProcessComparisonTool.js +2 -0
  68. package/dist/server/registerSessionTools.js +15 -2
  69. package/dist/server/runDerivedOperation.js +10 -2
  70. package/package.json +2 -1
  71. package/scripts/rea.mjs +1 -1
  72. package/skills/rea-analysis/SKILL.md +18 -3
@@ -85,7 +85,7 @@ export class EvidenceLedger {
85
85
  }
86
86
  /** Export records in deterministic, semantically irrelevant ID order. */
87
87
  export() {
88
- return createEvidenceBundle([...this.#records.values()], [...this.#unknownRevisions.values()]);
88
+ return structuredClone(createEvidenceBundle([...this.#records.values()], [...this.#unknownRevisions.values()]));
89
89
  }
90
90
  /** Check whether one immutable Evidence ID is present in this ledger. */
91
91
  has(evidenceId) {
@@ -162,7 +162,7 @@ export class EvidenceLedger {
162
162
  if (!checked.ok)
163
163
  return checked;
164
164
  this.#commit(pendingRecords, pendingUnknowns, checked.value);
165
- return ok(unknown);
165
+ return ok(structuredClone(unknown));
166
166
  }
167
167
  /** Apply one approved full-state update using optimistic revision matching. */
168
168
  updateUnknown(input, mutationEvidence) {
@@ -187,7 +187,8 @@ export class EvidenceLedger {
187
187
  (filters.severity === undefined ||
188
188
  unknown.severity === filters.severity) &&
189
189
  (filters.domain === undefined || unknown.domain === filters.domain))
190
- .sort((left, right) => left.unknown_id.localeCompare(right.unknown_id));
190
+ .sort((left, right) => left.unknown_id.localeCompare(right.unknown_id))
191
+ .map((unknown) => structuredClone(unknown));
191
192
  }
192
193
  /** Return current resolution validity; imported invalid states are rejected. */
193
194
  verifyUnknownResolution(unknownId) {
@@ -197,7 +198,7 @@ export class EvidenceLedger {
197
198
  return ok({
198
199
  valid: unknown.status === "resolved",
199
200
  truthVerified: unknown.resolution?.disposition === "verified",
200
- unknown,
201
+ unknown: structuredClone(unknown),
201
202
  });
202
203
  }
203
204
  /** Clear records when the owning session closes. */
@@ -225,7 +226,7 @@ export class EvidenceLedger {
225
226
  if (!checked.ok)
226
227
  return checked;
227
228
  this.#commit(pendingRecords, pendingUnknowns, checked.value);
228
- return ok(unknown);
229
+ return ok(structuredClone(unknown));
229
230
  }
230
231
  #validateCandidate(records, unknowns) {
231
232
  if (records.size + unknowns.size > this.limits.maxRecords)
@@ -1,6 +1,7 @@
1
1
  import { lstat, open, readFile, realpath, unlink, } from "node:fs/promises";
2
2
  import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
3
3
  import writeFileAtomic from "write-file-atomic";
4
+ import { isJsonWithinLimits } from "../domain/jsonLimits.js";
4
5
  import { InvestigationWorkspaceError } from "../domain/errors.js";
5
6
  import { parseInvestigationWorkspace, serializeInvestigationWorkspace, } from "../domain/investigationWorkspace.js";
6
7
  import { err, ok } from "../domain/result.js";
@@ -97,7 +98,7 @@ const readWorkspaceFile = async (destination, policy) => {
97
98
  catch (cause) {
98
99
  return err(new InvestigationWorkspaceError("read", "invalid-json", { cause }));
99
100
  }
100
- if (!withinJsonLimits(decoded, policy))
101
+ if (!isJsonWithinLimits(decoded, policy))
101
102
  return err(new InvestigationWorkspaceError("read", "too-large"));
102
103
  try {
103
104
  return ok(parseInvestigationWorkspace(decoded));
@@ -182,31 +183,6 @@ const validateNextRevision = (current, next, expectedRevision) => {
182
183
  ? ok(null)
183
184
  : err(new InvestigationWorkspaceError("update", "revision-conflict"));
184
185
  };
185
- const withinJsonLimits = (root, policy) => {
186
- const pending = [
187
- { value: root, depth: 0 },
188
- ];
189
- let nodes = 0;
190
- while (pending.length > 0) {
191
- const current = pending.pop();
192
- if (current === undefined)
193
- break;
194
- nodes += 1;
195
- if (nodes > policy.maxNodes || current.depth > policy.maxDepth)
196
- return false;
197
- if (typeof current.value === "string" &&
198
- current.value.length > policy.maxStringLength)
199
- return false;
200
- if (typeof current.value !== "object" || current.value === null)
201
- continue;
202
- for (const [key, value] of Object.entries(current.value)) {
203
- if (key.length > policy.maxStringLength)
204
- return false;
205
- pending.push({ value, depth: current.depth + 1 });
206
- }
207
- }
208
- return true;
209
- };
210
186
  const isFileNotFound = (cause) => errorCode(cause) === "ENOENT";
211
187
  const isAlreadyExists = (cause) => errorCode(cause) === "EEXIST";
212
188
  const errorCode = (cause) => typeof cause === "object" && cause !== null && "code" in cause
@@ -1,14 +1,15 @@
1
1
  import { realpath } from "node:fs/promises";
2
2
  import { ProcessCaptureError, processCaptureCancelled, } from "./ProcessCaptureError.js";
3
+ import { canonicalizeConfiguredRoots } from "./ConfiguredRoots.js";
3
4
  const isWithin = (candidate, root) => candidate === root ||
4
5
  candidate.startsWith(`${root.endsWith("/") ? root.slice(0, -1) : root}/`);
5
6
  export const assertRealPathAuthority = async (scenario, policy) => {
6
7
  const executable = await realpath(scenario.executable);
7
- const executableRoots = await Promise.all(policy.executableRoots.map((root) => realpath(root)));
8
+ const executableRoots = await canonicalizeConfiguredRoots(policy.executableRoots);
8
9
  if (!executableRoots.some((root) => isWithin(executable, root)))
9
10
  throw new ProcessCaptureError("resolved executable is outside approved roots");
10
11
  const workingDirectory = await realpath(scenario.working_directory);
11
- const workingRoots = await Promise.all(policy.workingRoots.map((root) => realpath(root)));
12
+ const workingRoots = await canonicalizeConfiguredRoots(policy.workingRoots);
12
13
  if (!workingRoots.some((root) => isWithin(workingDirectory, root)))
13
14
  throw new ProcessCaptureError("resolved working directory is outside approved roots");
14
15
  for (const root of scenario.filesystem_roots) {
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { createReadStream } from "node:fs";
3
- import { mkdtemp, rm } from "node:fs/promises";
3
+ import { mkdir, mkdtemp, rm } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { authorizeProcessScenario, digestProcessCommitment, processComparisonContract, processScenarioCommitment, } from "../domain/processCapture.js";
@@ -219,7 +219,6 @@ export const captureTerminalFrames = (options) => {
219
219
  let outputBytes = 0;
220
220
  let truncated = false;
221
221
  options.terminal.onData((data) => {
222
- options.onFirstOutput();
223
222
  options.onOutput();
224
223
  const bytes = Buffer.byteLength(data);
225
224
  if (options.frames.length >= options.scenario.limits.frames ||
@@ -256,7 +255,7 @@ export const resolveProcessResult = (capture, executionFailure, cleanupFailure)
256
255
  throw new ProcessCaptureError("process capture produced no result");
257
256
  return capture;
258
257
  };
259
- export const prepareProcessCapture = async (scenario, policy, signal) => {
258
+ export const prepareProcessCapture = async (scenario, policy, signal, host = systemProcessPreparationHost) => {
260
259
  const decision = authorizeProcessScenario(scenario, policy);
261
260
  if (!decision.allowed)
262
261
  throw new ProcessCaptureError(decision.reason, {
@@ -266,11 +265,22 @@ export const prepareProcessCapture = async (scenario, policy, signal) => {
266
265
  await assertRealPathAuthority(scenario, policy);
267
266
  assertNotCancelled(signal);
268
267
  const before = await snapshotRoots(scenario, signal);
269
- const temporaryRoot = await mkdtemp(join(tmpdir(), "rea-process-"));
270
- const runId = randomUUID();
271
- const home = join(temporaryRoot, "home");
272
- await import("node:fs/promises").then(({ mkdir }) => mkdir(home));
273
- return { temporaryRoot, runId, home, before };
268
+ const temporaryRoot = await host.createTemporaryRoot();
269
+ try {
270
+ const runId = randomUUID();
271
+ const home = join(temporaryRoot, "home");
272
+ await host.createHome(home);
273
+ return { temporaryRoot, runId, home, before };
274
+ }
275
+ catch (cause) {
276
+ await host.cleanup(temporaryRoot);
277
+ throw cause;
278
+ }
279
+ };
280
+ const systemProcessPreparationHost = {
281
+ createTemporaryRoot: () => mkdtemp(join(tmpdir(), "rea-process-")),
282
+ createHome: (path) => mkdir(path),
283
+ cleanup: (path) => rm(path, { recursive: true, force: true }),
274
284
  };
275
285
  const processPolicyMessage = (reason) => {
276
286
  if (reason === "process capture is disabled")
@@ -105,7 +105,7 @@ export class ProcessCheckpoints {
105
105
  effects: classifyFilesystemEffects(previous, after.files),
106
106
  truncated: after.truncated,
107
107
  });
108
- return this.#captures;
108
+ return structuredClone(this.#captures);
109
109
  }
110
110
  /** Cancel scheduled captures and await any snapshot already in progress. */
111
111
  async dispose() {
@@ -79,7 +79,13 @@ export const compareProcessEvidenceFiles = async (leftPath, rightPath) => {
79
79
  }
80
80
  };
81
81
  const parseCaptureEvidence = (input) => {
82
- const evidence = parseEvidence(input);
82
+ let evidence;
83
+ try {
84
+ evidence = parseEvidence(input);
85
+ }
86
+ catch {
87
+ throw invalidCaptureEvidence();
88
+ }
83
89
  if (evidence.operation !== "capture_process_scenario" ||
84
90
  evidence.predicate_type !== "rea.process-capture/v4" ||
85
91
  evidence.provider.id !== PROCESS_PROVIDER.id ||
@@ -88,11 +94,17 @@ const parseCaptureEvidence = (input) => {
88
94
  throw new ProcessCliFailure("invalid_input", LEGACY_PROCESS_CAPTURE_MESSAGE);
89
95
  throw new ProcessCliFailure("invalid_input", "Capture evidence is not from the current process-capture workflow. Create new capture evidence, then try again.");
90
96
  }
91
- return {
92
- id: evidence.evidence_id,
93
- capture: parseProcessCapture(evidence.normalized_result),
94
- };
97
+ try {
98
+ return {
99
+ id: evidence.evidence_id,
100
+ capture: parseProcessCapture(evidence.normalized_result),
101
+ };
102
+ }
103
+ catch {
104
+ throw invalidCaptureEvidence();
105
+ }
95
106
  };
107
+ const invalidCaptureEvidence = () => new ProcessCliFailure("invalid_input", "Capture evidence is malformed. Create new capture evidence, then try again.");
96
108
  const readJson = async (path) => {
97
109
  let bytes;
98
110
  try {
@@ -81,16 +81,6 @@ const scheduleScenarioEvents = (options) => {
81
81
  timers.add(timer);
82
82
  }
83
83
  };
84
- const startScenarioEvents = (options) => {
85
- let eventsStarted = false;
86
- return () => {
87
- if (eventsStarted)
88
- return;
89
- eventsStarted = true;
90
- scheduleScenarioEvents(options);
91
- };
92
- };
93
- const eventsRequireReadiness = (scenario) => scenario.events.some((event) => event.type === "input" || event.type === "resize");
94
84
  const cleanupFailedStartup = async (options) => {
95
85
  options.terminal?.kill("SIGKILL");
96
86
  const cleanupFailure = await releaseProcessResources({
@@ -134,23 +124,20 @@ const startCaptureRuntime = async (options) => {
134
124
  rows: scenario.terminal.rows,
135
125
  name: "xterm-256color",
136
126
  });
137
- const startEvents = startScenarioEvents({
138
- ...options,
139
- getTerminal: () => terminal,
140
- renderer,
141
- started,
142
- });
143
- if (!eventsRequireReadiness(scenario))
144
- startEvents();
145
127
  const framesTruncated = captureTerminalFrames({
146
128
  ...options,
147
129
  terminal,
148
130
  started,
149
131
  onOutput: () => (lastOutput = Date.now()),
150
- onFirstOutput: startEvents,
151
132
  renderer,
152
133
  checkpoints,
153
134
  });
135
+ scheduleScenarioEvents({
136
+ ...options,
137
+ getTerminal: () => terminal,
138
+ renderer,
139
+ started,
140
+ });
154
141
  const stopSampler = startProcessSampler(terminal.pid, started, scenario.limits.processes, options.samples);
155
142
  return {
156
143
  replay,
@@ -14,7 +14,7 @@ export const normalizeProcessText = (value, scenario, temporaryRoot, pid) => {
14
14
  normalized = normalized.replaceAll(root, `<filesystem-root-${String(index)}>`);
15
15
  }
16
16
  if (scenario.normalization.pids)
17
- normalized = normalized.replaceAll(String(pid), "<pid>");
17
+ normalized = normalizePidTokens(normalized, [pid]);
18
18
  if (scenario.normalization.ports)
19
19
  normalized = normalized.replaceAll(/(?<=[:=])\d{2,5}\b/g, "<port>");
20
20
  for (const pattern of scenario.normalization.patterns)
@@ -35,6 +35,12 @@ export const normalizeProcessSamples = (samples, scenario, rootPid) => {
35
35
  for (const identifier of identifiers)
36
36
  if (identifier > 0 && !mapping.has(identifier))
37
37
  mapping.set(identifier, mapping.size + 1);
38
+ const normalizeCommand = (command) => {
39
+ const normalized = normalizeProcessText(command, scenario, "<no-temporary-root>", rootPid);
40
+ return scenario.normalization.pids
41
+ ? normalizePidTokens(normalized, identifiers)
42
+ : normalized;
43
+ };
38
44
  return samples.map((sample) => ({
39
45
  at_ms: Math.floor(sample.at_ms / scenario.normalization.time_bucket_ms) *
40
46
  scenario.normalization.time_bucket_ms,
@@ -44,9 +50,18 @@ export const normalizeProcessSamples = (samples, scenario, rootPid) => {
44
50
  ? null
45
51
  : (mapping.get(sample.process_group_id) ?? 0),
46
52
  session_id: sample.session_id === null ? null : (mapping.get(sample.session_id) ?? 0),
47
- command: normalizeProcessText(sample.command, scenario, "<no-temporary-root>", rootPid),
53
+ command: normalizeCommand(sample.command),
48
54
  }));
49
55
  };
56
+ const normalizePidTokens = (value, identifiers) => {
57
+ const tokens = [...new Set(identifiers)]
58
+ .filter((identifier) => Number.isSafeInteger(identifier) && identifier > 0)
59
+ .sort((left, right) => right - left)
60
+ .map(String);
61
+ if (tokens.length === 0)
62
+ return value;
63
+ return value.replace(new RegExp(`(?<!\\d)(?:${tokens.join("|")})(?!\\d)`, "gu"), "<pid>");
64
+ };
50
65
  export const redactProtocolEvents = (events, scenario) => events.map((event) => ({
51
66
  ...event,
52
67
  data: normalizeProcessText(event.data, scenario, "<no-temporary-root>", -1),
@@ -2,9 +2,10 @@ import { execFile } from "node:child_process";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { promisify } from "node:util";
4
4
  const execFileAsync = promisify(execFile);
5
- const parseEnvironment = (value) => Object.fromEntries(value
5
+ /** Parse the NUL-delimited Linux process environment without nameless keys. */
6
+ export const parseProcessEnvironment = (value) => Object.fromEntries(value
6
7
  .split("\0")
7
- .filter((entry) => entry.includes("="))
8
+ .filter((entry) => entry.indexOf("=") > 0)
8
9
  .map((entry) => {
9
10
  const separator = entry.indexOf("=");
10
11
  return [entry.slice(0, separator), entry.slice(separator + 1)];
@@ -31,7 +32,7 @@ const systemHost = {
31
32
  },
32
33
  async environment(pid) {
33
34
  if (process.platform === "linux") {
34
- return parseEnvironment(await readFile(`/proc/${pid}/environ`, "utf8"));
35
+ return parseProcessEnvironment(await readFile(`/proc/${pid}/environ`, "utf8"));
35
36
  }
36
37
  const { stdout } = await execFileAsync("ps", ["eww", "-p", String(pid)]);
37
38
  const environment = {};
@@ -1,5 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
- import { readFile } from "node:fs/promises";
2
+ import { readFile, readdir } from "node:fs/promises";
3
3
  import { promisify } from "node:util";
4
4
  const execFileAsync = promisify(execFile);
5
5
  const PROC_READ_CONCURRENCY = 64;
@@ -67,23 +67,58 @@ const readProcessCommand = async (pid, signal) => {
67
67
  return handleProcReadFailure(cause);
68
68
  }
69
69
  };
70
- const readLinuxChildren = async (pid, signal) => {
71
- let text;
72
- try {
73
- text = await readFile(`/proc/${String(pid)}/task/${String(pid)}/children`, {
74
- encoding: "utf8",
75
- signal,
76
- });
77
- }
78
- catch (cause) {
79
- return handleProcReadFailure(cause);
70
+ const systemLinuxChildrenHost = {
71
+ async taskIds(pid, signal) {
72
+ try {
73
+ signal.throwIfAborted();
74
+ const entries = await readdir(`/proc/${String(pid)}/task`);
75
+ signal.throwIfAborted();
76
+ return entries
77
+ .filter((entry) => /^\d+$/.test(entry))
78
+ .map(Number)
79
+ .filter((taskId) => Number.isSafeInteger(taskId) && taskId > 0)
80
+ .sort((left, right) => left - right);
81
+ }
82
+ catch (cause) {
83
+ return handleProcReadFailure(cause);
84
+ }
85
+ },
86
+ async children(pid, taskId, signal) {
87
+ try {
88
+ return await readFile(`/proc/${String(pid)}/task/${String(taskId)}/children`, { encoding: "utf8", signal });
89
+ }
90
+ catch (cause) {
91
+ return handleProcReadFailure(cause);
92
+ }
93
+ },
94
+ };
95
+ /** Read the deduplicated children created by every thread in a Linux process. */
96
+ export const readLinuxChildren = async (pid, signal, host = systemLinuxChildrenHost) => {
97
+ const taskIds = await host.taskIds(pid, signal);
98
+ if (taskIds === undefined)
99
+ return undefined;
100
+ const children = new Set();
101
+ let successfulReads = 0;
102
+ for (let offset = 0; offset < taskIds.length; offset += PROC_READ_CONCURRENCY) {
103
+ const texts = await Promise.all(taskIds
104
+ .slice(offset, offset + PROC_READ_CONCURRENCY)
105
+ .map((taskId) => host.children(pid, taskId, signal)));
106
+ for (const text of texts) {
107
+ if (text === undefined)
108
+ continue;
109
+ successfulReads += 1;
110
+ for (const value of text.trim().split(/\s+/)) {
111
+ if (!/^\d+$/.test(value))
112
+ continue;
113
+ const child = Number(value);
114
+ if (Number.isSafeInteger(child) && child > 0)
115
+ children.add(child);
116
+ }
117
+ }
80
118
  }
81
- return text
82
- .trim()
83
- .split(/\s+/)
84
- .filter((value) => /^\d+$/.test(value))
85
- .map(Number)
86
- .filter((child) => Number.isSafeInteger(child) && child > 0);
119
+ return successfulReads === 0
120
+ ? undefined
121
+ : [...children].sort((left, right) => left - right);
87
122
  };
88
123
  const inspectProcess = async (pid, expectedParent, signal, identities) => {
89
124
  const before = await readProcessStat(pid, signal);
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
- import { mkdir, readFile, realpath, stat } from "node:fs/promises";
2
+ import { lstat, mkdir, open, readFile, realpath, stat, unlink, } from "node:fs/promises";
3
3
  import { dirname } from "node:path";
4
+ import { setTimeout as delay } from "node:timers/promises";
4
5
  import writeFileAtomic from "write-file-atomic";
5
6
  import { z } from "zod";
6
7
  import { err, ok } from "../domain/result.js";
@@ -8,6 +9,7 @@ const grantSchema = z.object({
8
9
  grant_id: z.string().min(1),
9
10
  capability: z.enum([
10
11
  "process_capture",
12
+ "browser_observe",
11
13
  "evidence_read",
12
14
  "evidence_write",
13
15
  "investigation_input",
@@ -22,6 +24,7 @@ const grantSchema = z.object({
22
24
  roots: z.array(z.string()),
23
25
  executables: z.array(z.string()),
24
26
  environment_names: z.array(z.string()),
27
+ origins: z.array(z.string()).optional(),
25
28
  network: z.enum(["none", "loopback", "external"]),
26
29
  mount: z.boolean(),
27
30
  lifetime: z.literal("project"),
@@ -65,7 +68,15 @@ export const readProjectPermissionStore = async (path, projectRoot) => {
65
68
  const metadata = await stat(path);
66
69
  if ((metadata.mode & 0o077) !== 0)
67
70
  return err(new ProjectPermissionStoreError("not_owner_only"));
68
- const parsed = storeSchema.safeParse(JSON.parse(await readFile(path, "utf8")));
71
+ const encoded = await readFile(path, "utf8");
72
+ let decoded;
73
+ try {
74
+ decoded = JSON.parse(encoded);
75
+ }
76
+ catch (cause) {
77
+ return err(new ProjectPermissionStoreError("invalid", { cause }));
78
+ }
79
+ const parsed = storeSchema.safeParse(decoded);
69
80
  if (!parsed.success ||
70
81
  parsed.data.project_id !== project.value.id ||
71
82
  parsed.data.project_root !== project.value.root)
@@ -107,4 +118,96 @@ export const writeProjectPermissionStore = async (path, projectRoot, grants) =>
107
118
  return err(new ProjectPermissionStoreError("io", { cause }));
108
119
  }
109
120
  };
110
- const isNotFound = (cause) => cause instanceof Error && "code" in cause && cause.code === "ENOENT";
121
+ /** Revoke one grant while serializing the complete read-modify-write cycle. */
122
+ export const revokeProjectPermissionGrant = async (path, projectRoot, grantId) => {
123
+ let lock;
124
+ try {
125
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
126
+ const acquired = await acquirePermissionStoreLock(path);
127
+ if (!acquired.ok)
128
+ return acquired;
129
+ lock = acquired.value;
130
+ const current = await readProjectPermissionStore(path, projectRoot);
131
+ if (!current.ok)
132
+ return current;
133
+ const grants = current.value?.grants ?? [];
134
+ const retained = grants.filter(({ grant_id }) => grant_id !== grantId);
135
+ if (retained.length === grants.length)
136
+ return ok(false);
137
+ const written = await writeProjectPermissionStore(path, projectRoot, retained);
138
+ return written.ok ? ok(true) : written;
139
+ }
140
+ catch (cause) {
141
+ return err(new ProjectPermissionStoreError("io", { cause }));
142
+ }
143
+ finally {
144
+ if (lock !== undefined)
145
+ await releasePermissionStoreLock(lock);
146
+ }
147
+ };
148
+ const acquirePermissionStoreLock = async (destination) => {
149
+ const path = `${destination}.lock`;
150
+ for (let attempt = 0; attempt < 100; attempt += 1) {
151
+ try {
152
+ return ok(await createPermissionStoreLock(path));
153
+ }
154
+ catch (cause) {
155
+ if (!isAlreadyExists(cause))
156
+ return err(new ProjectPermissionStoreError("io", { cause }));
157
+ if (await removeStalePermissionStoreLock(path))
158
+ continue;
159
+ if (attempt === 99)
160
+ return err(new ProjectPermissionStoreError("locked", { cause }));
161
+ await delay(10);
162
+ }
163
+ }
164
+ return err(new ProjectPermissionStoreError("locked"));
165
+ };
166
+ const createPermissionStoreLock = async (path) => {
167
+ const handle = await open(path, "wx", 0o600);
168
+ try {
169
+ await handle.writeFile(`${String(process.pid)}\n`, "utf8");
170
+ await handle.sync();
171
+ return { path, handle };
172
+ }
173
+ catch (cause) {
174
+ await handle.close().catch(() => undefined);
175
+ await unlink(path).catch(() => undefined);
176
+ throw cause;
177
+ }
178
+ };
179
+ const removeStalePermissionStoreLock = async (path) => {
180
+ try {
181
+ const metadata = await lstat(path);
182
+ if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > 32)
183
+ return false;
184
+ const encoded = (await readFile(path, "utf8")).trim();
185
+ if (!/^[1-9][0-9]{0,9}$/u.test(encoded))
186
+ return false;
187
+ if (processIsAlive(Number(encoded)))
188
+ return false;
189
+ await unlink(path);
190
+ return true;
191
+ }
192
+ catch {
193
+ return false;
194
+ }
195
+ };
196
+ const processIsAlive = (pid) => {
197
+ try {
198
+ process.kill(pid, 0);
199
+ return true;
200
+ }
201
+ catch (cause) {
202
+ return errorCode(cause) !== "ESRCH";
203
+ }
204
+ };
205
+ const releasePermissionStoreLock = async (lock) => {
206
+ await lock.handle.close().catch(() => undefined);
207
+ await unlink(lock.path).catch(() => undefined);
208
+ };
209
+ const isNotFound = (cause) => errorCode(cause) === "ENOENT";
210
+ const isAlreadyExists = (cause) => errorCode(cause) === "EEXIST";
211
+ const errorCode = (cause) => typeof cause === "object" && cause !== null && "code" in cause
212
+ ? cause.code
213
+ : undefined;
@@ -4,6 +4,7 @@ import ignore from "ignore";
4
4
  import { err, ok } from "../domain/result.js";
5
5
  import { withinRoot } from "../reference/ReferenceSourceReaderPaths.js";
6
6
  import { DEFAULT_REFERENCE_SOURCE_IGNORE_PATTERNS, DEFAULT_REFERENCE_SOURCE_LIMITS, } from "./ReferenceSourceImportTypes.js";
7
+ import { canonicalizeConfiguredRoots } from "./ConfiguredRoots.js";
7
8
  const failure = (code, message) => ({
8
9
  tag: "reference-source-import",
9
10
  code,
@@ -29,8 +30,8 @@ const authorizeRoot = async (requestedRoot, approvedRoots) => {
29
30
  if (!(await stat(requestedRoot)).isDirectory())
30
31
  return err(failure("invalid-root", "Reference source root is not a directory"));
31
32
  const canonicalRoot = await realpath(resolve(requestedRoot));
32
- for (const approvedRoot of approvedRoots) {
33
- const canonicalApproved = await realpath(resolve(approvedRoot));
33
+ const canonicalApprovedRoots = await canonicalizeConfiguredRoots(approvedRoots.map((approvedRoot) => resolve(approvedRoot)));
34
+ for (const canonicalApproved of canonicalApprovedRoots) {
34
35
  if (withinRoot(canonicalApproved, canonicalRoot))
35
36
  return ok(canonicalRoot);
36
37
  }
@@ -49,7 +49,7 @@ export class TerminalRenderer {
49
49
  /** Await all queued parsing and return immutable rendered observations. */
50
50
  async frames() {
51
51
  await this.#pending;
52
- return this.#frames;
52
+ return structuredClone(this.#frames);
53
53
  }
54
54
  /** Whether a rendered observation exceeded its independent capture budget. */
55
55
  truncated() {
@@ -59,7 +59,7 @@ export class MachOSliceArtifactReader {
59
59
  return Promise.resolve(createReadStream(this.path, { start: offset, end: offset + size - 1 }));
60
60
  }
61
61
  provenance() {
62
- return this.#command === undefined ? [] : [this.#command];
62
+ return this.#command === undefined ? [] : [structuredClone(this.#command)];
63
63
  }
64
64
  close() {
65
65
  return Promise.resolve();
@@ -67,7 +67,7 @@ export class NativeDmgArtifactReader {
67
67
  return this.#directory.open(entry, signal);
68
68
  }
69
69
  provenance() {
70
- return this.#provenance;
70
+ return structuredClone(this.#provenance);
71
71
  }
72
72
  async close() {
73
73
  let detachFailure;
@@ -59,7 +59,7 @@ export class SafeOutputTree {
59
59
  return this.#outputRoot;
60
60
  }
61
61
  get cleanup() {
62
- return this.#cleanup;
62
+ return structuredClone(this.#cleanup);
63
63
  }
64
64
  /** Stream one selected regular file with exact byte and digest verification. */
65
65
  async write(relativePath, source, expectedSha256, signal) {
@@ -123,7 +123,7 @@ export class SafeOutputTree {
123
123
  /** Remove only this operation's unsealed tree and verify absence. */
124
124
  async rollback() {
125
125
  if (this.#published)
126
- return this.#cleanup;
126
+ return structuredClone(this.#cleanup);
127
127
  await rm(this.#stagingRoot, { recursive: true, force: true });
128
128
  const absent = await isAbsent(this.#stagingRoot);
129
129
  this.#cleanup = {
@@ -133,7 +133,7 @@ export class SafeOutputTree {
133
133
  };
134
134
  if (!absent)
135
135
  throw new ArtifactReaderFailure("integrity", "Extraction output cleanup could not be verified");
136
- return this.#cleanup;
136
+ return structuredClone(this.#cleanup);
137
137
  }
138
138
  async #prepareParent(relativePath) {
139
139
  const parts = relativePath.split("/");