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
package/README.md CHANGED
@@ -10,16 +10,20 @@
10
10
 
11
11
  [![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
12
12
  [![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
13
- [![68 MCP tools](https://img.shields.io/badge/MCP_tools-68-5c4ee5?style=flat-square)](#68-tools-for-investigation)
13
+ [![70 MCP tools](https://img.shields.io/badge/MCP_tools-70-5c4ee5?style=flat-square)](#70-tools-for-investigation)
14
14
  [![Node.js 22+](https://img.shields.io/badge/Node.js-22.19%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/)
15
15
  [![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE)
16
16
 
17
- [Quick start](#quick-start) · [Current status](#current-status) · [Investigation model](#the-investigation-model) · [68 tools](#68-tools-for-investigation) · [Roadmap](#roadmap) · [How it works](#how-it-works)
17
+ [Quick start](#quick-start) · [Current status](#current-status) · [Investigation model](#the-investigation-model) · [70 tools](#70-tools-for-investigation) · [Roadmap](#roadmap) · [How it works](#how-it-works)
18
18
 
19
19
  <br />
20
20
 
21
21
  <code>npm install --global rea-agents && rea setup</code>
22
22
 
23
+ <br />
24
+
25
+ <img src="docs/assets/rea-hopper-analysis.png" alt="REA launching its analysis bridge inside Hopper while inspecting a native binary" width="1200" />
26
+
23
27
  </div>
24
28
 
25
29
  ---
@@ -290,15 +294,16 @@ REA handles the app analysis in steps 1–5. The agent performs step 6 with its
290
294
  - Analyze Swift and Objective-C metadata without manually untangling every mangled symbol.
291
295
  - Leave names, comments, and bookmarks in Hopper so human and agent analysis reinforce each other.
292
296
 
293
- ## 68 tools for investigation
297
+ ## 70 tools for investigation
294
298
 
295
- | Tool family | Count | Examples |
296
- | ------------------------- | ----: | ----------------------------------------------------------------------------------------------------------------------- |
297
- | Native inspection | 33 | procedures, pseudocode, assembly, strings, names, segments, callers, callees, xrefs, annotations |
298
- | Investigation workflows | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `trace_feature`, call graphs, Swift and Objective-C discovery |
299
- | Native macOS utilities | 5 | Mach-O metadata, code signatures, plists, architectures, Swift demangling; Hopper-free and provenance-bearing |
300
- | Artifact graph | 2 | deterministic directory, ZIP/APK/IPA, and ASAR inventory; explicitly selected extraction into an absent owned tree |
301
- | Workspace and observation | 18 | target lifecycle, Evidence v2 bundles, process/artifact/function comparison, evidence-linked residual-unknown lifecycle |
299
+ | Tool family | Count | Examples |
300
+ | ------------------------- | ----: | ---------------------------------------------------------------------------------------------------------------------------------- |
301
+ | Native inspection | 33 | procedures, pseudocode, assembly, strings, names, segments, callers, callees, xrefs, annotations |
302
+ | Investigation workflows | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `trace_feature`, call graphs, Swift and Objective-C discovery |
303
+ | Native macOS utilities | 5 | Mach-O metadata, code signatures, plists, architectures, Swift demangling; Hopper-free and provenance-bearing |
304
+ | Artifact graph | 2 | deterministic directory, ZIP/APK/IPA, and ASAR inventory; explicitly selected extraction into an absent owned tree |
305
+ | Browser observation | 2 | exact-origin CDP page discovery and passive DOM, accessibility, script, resource, network, console, worker, and storage inspection |
306
+ | Workspace and observation | 18 | target lifecycle, Evidence v2 bundles, process/artifact/function comparison, evidence-linked residual-unknown lifecycle |
302
307
 
303
308
  The public interface describes what the agent is trying to learn. Providers decide how to answer. macOS utilities handle common semantic inspection without launching Hopper; Hopper handles deeper native analysis; the process harness implements controlled behavioral capture.
304
309
 
@@ -307,6 +312,7 @@ The public interface describes what the agent is trying to learn. Providers deci
307
312
  REA is already useful for native application investigation on macOS:
308
313
 
309
314
  - Open Mach-O, ELF, PE, `.app`, ZIP, APK, IPA, ASAR, plist, JavaScript, source-map, and Hopper database targets.
315
+ - Attach to a user-owned Chrome-family browser over a configured loopback CDP endpoint, list only pages on approved exact origins, and capture bounded passive web evidence without navigation or JavaScript evaluation.
310
316
  - Traverse content-addressed artifact graphs without extraction; on macOS, read-only DMG traversal additionally requires `native_mount_approved: true` and `REA_ARTIFACT_NATIVE_MOUNT_ENABLED=true`. Materialize only approved occurrences into absent output roots.
311
317
  - Build bounded function dossiers with pseudocode, assembly, CFG edges, comments, calls, references, strings, and names.
312
318
  - Search and trace features across symbols, strings, metadata, references, and call paths.
@@ -327,12 +333,27 @@ REA is already useful for native application investigation on macOS:
327
333
 
328
334
  Hopper is the first provider, not the boundary of the project. Some current workflows still require Hopper and macOS; every evidence record identifies the provider and limitations behind its result.
329
335
 
336
+ ### Website observation with CDP
337
+
338
+ REA can inspect an already-running Chrome-family browser that you own. Browser observation is disabled by default and requires a literal loopback CDP endpoint plus exact approved page origins:
339
+
340
+ ```bash
341
+ export REA_BROWSER_OBSERVE_ENABLED=true
342
+ export REA_BROWSER_CDP_ENDPOINTS_JSON='["http://127.0.0.1:9222"]'
343
+ export REA_BROWSER_ALLOWED_ORIGINS_JSON='["http://127.0.0.1:3000"]'
344
+
345
+ rea list-browser-targets http://127.0.0.1:9222 --approved --json
346
+ rea inspect-web-page http://127.0.0.1:9222 TARGET_ID --approved --json
347
+ ```
348
+
349
+ `list_browser_targets` and `inspect_web_page` expose the same Evidence v2 contracts over MCP. Inspection is passive: REA does not evaluate page JavaScript, navigate, click, close the page, or close the browser. It redacts query values and never retains headers, bodies, cookies, storage values, console argument values, or WebSocket payloads. Existing activity before attach is explicitly unavailable. See [Website observation with CDP](docs/browser-observation.md) for browser startup, schemas, limits, and the threat model.
350
+
330
351
  ## Roadmap
331
352
 
332
353
  REA is growing into a toolkit for understanding software across static artifacts and observed behavior. The next capability families are:
333
354
 
334
355
  1. **Artifact decomposition** — DMG, ASAR, ZIP, packages, universal-binary slices, application resources, embedded frameworks, mobile packages, and artifact graphs.
335
- 2. **Web and Electron investigation** — Playwright/CDP capture of DOM, accessibility trees, screenshots, storage, console, IPC, HTTP, WebSocket, routes, and visual or structural differences.
356
+ 2. **Web and Electron investigation** — extend the shipped passive CDP observation with approved interaction, screenshots, Electron IPC, route discovery, controlled replay, and visual or structural differences.
336
357
  3. **Deterministic behavior harnesses** — stronger process-tree ownership, protocol fixtures, network policy, filesystem tracing, signals, reconnects, and cross-version comparison.
337
358
  4. **JavaScript and source recovery** — bundle indexing, AST/module reconstruction, source-map discovery, historical-source matching, and CodeDB-backed cross-references.
338
359
  5. **Runtime observation** — approval-gated LLDB, Frida, system logs, process and filesystem observers, and native API tracing.
@@ -377,8 +398,9 @@ flowchart LR
377
398
  Router --> Hopper["Hopper provider"]
378
399
  Router --> Native["Native macOS provider"]
379
400
  Router --> Artifact["Artifact graph provider"]
401
+ Router --> Browser["Browser CDP provider"]
380
402
  Router --> Process["Process capture provider"]
381
- Router -. roadmap .-> More["Browser, dynamic,<br/>and additional static providers"]
403
+ Router -. roadmap .-> More["Dynamic and additional<br/>static providers"]
382
404
  Hopper --> Target["Target software"]
383
405
  Process --> Target
384
406
  Native --> Target
@@ -52,12 +52,12 @@ export class AnalysisSnapshotCache {
52
52
  this.#target.format !== snapshot.target.format ||
53
53
  this.#target.architecture !== snapshot.target.architecture))
54
54
  this.#entries.clear();
55
- this.#target = snapshot.target;
55
+ this.#target = structuredClone(snapshot.target);
56
56
  let imported = 0;
57
57
  for (const entry of snapshot.entries) {
58
58
  if (!this.#entries.has(entry.query_id))
59
59
  imported += 1;
60
- this.#entries.set(entry.query_id, entry);
60
+ this.#entries.set(entry.query_id, structuredClone(entry));
61
61
  }
62
62
  return imported;
63
63
  }
@@ -69,7 +69,7 @@ export class AnalysisSnapshotCache {
69
69
  snapshot_version: 1,
70
70
  target: snapshotTarget(target),
71
71
  entries: this.entries(),
72
- evidence_bundle: evidenceBundle,
72
+ evidence_bundle: structuredClone(evidenceBundle),
73
73
  });
74
74
  }
75
75
  /** Validate target identity, merge evidence atomically, then stage entries. */
@@ -81,7 +81,9 @@ export class AnalysisSnapshotCache {
81
81
  }
82
82
  /** Return canonical entries for persistence. */
83
83
  entries() {
84
- return [...this.#entries.values()].sort((left, right) => left.query_id.localeCompare(right.query_id));
84
+ return [...this.#entries.values()]
85
+ .sort((left, right) => left.query_id.localeCompare(right.query_id))
86
+ .map((entry) => structuredClone(entry));
85
87
  }
86
88
  /** Replay an exact provider-specific query, marking its cached provenance. */
87
89
  lookup(target, operation, parameters, provider) {
@@ -90,7 +92,7 @@ export class AnalysisSnapshotCache {
90
92
  if (cached === undefined)
91
93
  return undefined;
92
94
  const subject = cached.execution.subject;
93
- return {
95
+ return structuredClone({
94
96
  result: cached.execution.result,
95
97
  rawResult: cached.execution.raw_result,
96
98
  provider: cached.execution.provider,
@@ -113,7 +115,7 @@ export class AnalysisSnapshotCache {
113
115
  format: subject.format,
114
116
  architecture: subject.architecture,
115
117
  },
116
- };
118
+ });
117
119
  }
118
120
  /** Record one successful immutable call unless the cache is full. */
119
121
  record(target, operation, parameters, execution) {
@@ -1,6 +1,14 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { extname } from "node:path";
3
3
  import canonicalize from "canonicalize";
4
+ /** Index the first occurrence for each canonical logical path. */
5
+ export const indexOccurrencesByPath = (occurrences) => {
6
+ const byPath = new Map();
7
+ for (const occurrence of occurrences)
8
+ if (!byPath.has(occurrence.logical_path))
9
+ byPath.set(occurrence.logical_path, occurrence);
10
+ return byPath;
11
+ };
4
12
  export const createOccurrence = (entry, path, parent) => ({
5
13
  occurrence_id: `occ_${digestCanonical({ path, kind: entry.kind })}`,
6
14
  artifact_id: null,
@@ -118,13 +126,14 @@ export const rekeyOccurrences = (rootArtifactId, occurrences) => {
118
126
  };
119
127
  export const createArtifactEdges = (rootArtifactId, occurrences, producer) => {
120
128
  const byId = new Map(occurrences.map((item) => [item.occurrence_id, item]));
129
+ const byPath = indexOccurrencesByPath(occurrences);
121
130
  const edges = [];
122
131
  for (const occurrence of occurrences) {
123
132
  if (occurrence.parent_occurrence_id === null ||
124
133
  occurrence.artifact_id === null)
125
134
  continue;
126
- const mappedSource = occurrence.logical_path.endsWith(".map")
127
- ? occurrences.find(({ logical_path: path }) => path === occurrence.logical_path.slice(0, -".map".length))
135
+ const mappedSource = occurrence.logical_path.toLowerCase().endsWith(".map")
136
+ ? byPath.get(occurrence.logical_path.slice(0, -".map".length))
128
137
  : undefined;
129
138
  const parentArtifactId = mappedSource?.artifact_id ??
130
139
  byId.get(occurrence.parent_occurrence_id)?.artifact_id ??
@@ -10,7 +10,7 @@ import { MachOSliceArtifactReader } from "../artifacts/MachOSliceArtifactReader.
10
10
  import { NativeDmgArtifactReader } from "../artifacts/NativeDmgArtifactReader.js";
11
11
  import { streamChunkToBuffer } from "../artifacts/StreamBytes.js";
12
12
  import { artifactInventoryResultSchema, } from "../domain/artifactGraph.js";
13
- import { classifyArtifactPath, classifyArtifactContent, createArtifactEdges, createArtifactNode, createOccurrence, createRootNode, digestCanonical, materializeDirectoryNodes, nearestParent, pageOf, rekeyOccurrences, rootOccurrenceFor, toOutputLimits, } from "./ArtifactGraphConstruction.js";
13
+ import { classifyArtifactPath, classifyArtifactContent, createArtifactEdges, createArtifactNode, createOccurrence, createRootNode, digestCanonical, indexOccurrencesByPath, materializeDirectoryNodes, nearestParent, pageOf, rekeyOccurrences, rootOccurrenceFor, toOutputLimits, } from "./ArtifactGraphConstruction.js";
14
14
  const NATIVE_MOUNT_DISABLED = {
15
15
  nativeMountApproved: false,
16
16
  nativeMountEnabled: false,
@@ -61,8 +61,9 @@ export const scanCanonicalArtifactInventory = async (path, limits, signal, nativ
61
61
  occurrence.parent_occurrence_id = rootOccurrence.occurrence_id;
62
62
  occurrences.unshift(rootOccurrence);
63
63
  const occurrenceById = new Map(occurrences.map((occurrence) => [occurrence.occurrence_id, occurrence]));
64
+ const occurrenceByPath = indexOccurrencesByPath(occurrences);
64
65
  const integrityContradictions = pendingContradictions.map((contradiction) => {
65
- const occurrence = occurrences.find(({ logical_path: path }) => path === contradiction.logicalPath);
66
+ const occurrence = occurrenceByPath.get(contradiction.logicalPath);
66
67
  if (occurrence === undefined)
67
68
  throw new ArtifactReaderFailure("integrity", "Integrity contradiction lost its graph occurrence");
68
69
  const parent = occurrence.parent_occurrence_id === null
@@ -2,13 +2,14 @@ import { realpath } from "node:fs/promises";
2
2
  import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
3
3
  import { withinRoot } from "../reference/ReferenceSourceReaderPaths.js";
4
4
  import { scanCanonicalArtifactInventory, } from "./ArtifactInventory.js";
5
+ import { canonicalizeConfiguredRoots } from "./ConfiguredRoots.js";
5
6
  /** Resolve, authorize, and scan one artifact without a second path resolution. */
6
7
  export const scanAuthorizedArtifactInventory = async (inputPath, roots, limits, signal, integrity) => {
7
8
  if (roots.length === 0)
8
9
  throw new ArtifactReaderFailure("path", "Artifact input roots are disabled");
9
10
  const [path, approved] = await Promise.all([
10
11
  realpath(inputPath),
11
- Promise.all(roots.map(async (root) => realpath(root))),
12
+ canonicalizeConfiguredRoots(roots),
12
13
  ]);
13
14
  if (!approved.some((root) => withinRoot(root, path)))
14
15
  throw new ArtifactReaderFailure("path", "Artifact path is outside approved input roots");
@@ -61,7 +61,7 @@ export class BinarySession {
61
61
  if (operation !== undefined) {
62
62
  const exact = this.#capabilities?.get(operation)?.provider;
63
63
  if (exact !== undefined)
64
- return exact;
64
+ return structuredClone(exact);
65
65
  if (enhancedToolNameSchema.safeParse(operation).success) {
66
66
  const providers = new Map();
67
67
  for (const descriptor of this.#capabilities?.values() ?? [])
@@ -71,11 +71,11 @@ export class BinarySession {
71
71
  if (providers.size === 1) {
72
72
  const provider = providers.values().next().value;
73
73
  if (provider !== undefined)
74
- return provider;
74
+ return structuredClone(provider);
75
75
  }
76
76
  }
77
77
  }
78
- return this.#providerIdentity;
78
+ return structuredClone(this.#providerIdentity);
79
79
  }
80
80
  /** Observe runtime provider-health changes that affect discovery metadata. */
81
81
  onAvailabilityChanged(listener) {
@@ -130,12 +130,15 @@ export class BinarySession {
130
130
  }
131
131
  /** Read one session-retained immutable workspace revision. */
132
132
  investigationWorkspace(workspaceId, revision) {
133
- return this.#investigationWorkspaces.get(`${workspaceId}:${String(revision)}`);
133
+ const workspace = this.#investigationWorkspaces.get(`${workspaceId}:${String(revision)}`);
134
+ return workspace === undefined ? undefined : structuredClone(workspace);
134
135
  }
135
136
  /** List retained workspace revisions in canonical identity order. */
136
137
  investigationWorkspaces() {
137
- return [...this.#investigationWorkspaces.values()].sort((left, right) => left.workspace_id.localeCompare(right.workspace_id) ||
138
- left.revision - right.revision);
138
+ return [...this.#investigationWorkspaces.values()]
139
+ .sort((left, right) => left.workspace_id.localeCompare(right.workspace_id) ||
140
+ left.revision - right.revision)
141
+ .map((workspace) => structuredClone(workspace));
139
142
  }
140
143
  /** Create an approved residual unknown and immutable mutation evidence. */
141
144
  recordUnknown(input) {
@@ -310,7 +313,9 @@ export class BinarySession {
310
313
  }
311
314
  /** Return the immutable artifact identity captured before Hopper launched. */
312
315
  activeTarget() {
313
- return this.#active?.target;
316
+ return this.#active === undefined
317
+ ? undefined
318
+ : structuredClone(this.#active.target);
314
319
  }
315
320
  /**
316
321
  * Invoke a Hopper tool against the active target.
@@ -2,7 +2,9 @@ import { lstat, readFile, realpath } from "node:fs/promises";
2
2
  import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
3
3
  import writeFileAtomic from "write-file-atomic";
4
4
  import { EvidenceFileError } from "../domain/errors.js";
5
+ import { isJsonWithinLimits } from "../domain/jsonLimits.js";
5
6
  import { err, ok } from "../domain/result.js";
7
+ import { canonicalizeConfiguredRoots } from "./ConfiguredRoots.js";
6
8
  /** Read bounded JSON data from a regular file beneath an approved root. */
7
9
  export const readBoundedJson = async (path, policy) => {
8
10
  if (policy.roots.length === 0)
@@ -26,8 +28,9 @@ export const readBoundedJson = async (path, policy) => {
26
28
  catch (cause) {
27
29
  return err(new EvidenceFileError("read", "invalid-json", { cause }));
28
30
  }
29
- const limits = validateJsonLimits(decoded, policy);
30
- return limits.ok ? ok(decoded) : limits;
31
+ return isJsonWithinLimits(decoded, policy)
32
+ ? ok(decoded)
33
+ : err(new EvidenceFileError("read", "too-large"));
31
34
  }
32
35
  catch (cause) {
33
36
  return err(new EvidenceFileError("read", "io", { cause }));
@@ -69,8 +72,7 @@ export const writeBoundedText = async (encoded, path, overwrite, policy) => {
69
72
  }
70
73
  };
71
74
  const isApproved = async (candidate, roots) => {
72
- for (const configuredRoot of roots) {
73
- const root = await realpath(resolve(configuredRoot));
75
+ for (const root of await canonicalizeConfiguredRoots(roots.map((configuredRoot) => resolve(configuredRoot)))) {
74
76
  const relation = relative(root, candidate);
75
77
  if (relation === "" ||
76
78
  (!relation.startsWith("..") && !isAbsolute(relation)))
@@ -78,31 +80,6 @@ const isApproved = async (candidate, roots) => {
78
80
  }
79
81
  return false;
80
82
  };
81
- const validateJsonLimits = (root, policy) => {
82
- const pending = [
83
- { value: root, depth: 0 },
84
- ];
85
- let nodes = 0;
86
- while (pending.length > 0) {
87
- const current = pending.pop();
88
- if (current === undefined)
89
- break;
90
- nodes += 1;
91
- if (nodes > policy.maxNodes || current.depth > policy.maxDepth)
92
- return err(new EvidenceFileError("read", "too-large"));
93
- if (typeof current.value === "string" &&
94
- current.value.length > policy.maxStringLength)
95
- return err(new EvidenceFileError("read", "too-large"));
96
- if (typeof current.value !== "object" || current.value === null)
97
- continue;
98
- for (const [key, value] of Object.entries(current.value)) {
99
- if (key.length > policy.maxStringLength)
100
- return err(new EvidenceFileError("read", "too-large"));
101
- pending.push({ value, depth: current.depth + 1 });
102
- }
103
- }
104
- return ok(null);
105
- };
106
83
  const isFileNotFound = (cause) => typeof cause === "object" &&
107
84
  cause !== null &&
108
85
  "code" in cause &&
@@ -0,0 +1,35 @@
1
+ import { createEvidence, } from "../domain/evidence.js";
2
+ import { jsonValueSchema } from "../domain/jsonValue.js";
3
+ /** Create Evidence v2 for a policy-scoped external browser observation. */
4
+ export const createBrowserEvidence = (operation, input, result, provider) => createEvidence(undefined, provider, {
5
+ predicateType: operation === "list_browser_targets"
6
+ ? "rea.browser-target-list/v1"
7
+ : "rea.web-page-inspection/v1",
8
+ operation,
9
+ parameters: browserParameters(input),
10
+ result: jsonValueSchema.parse(result),
11
+ confidence: "observed",
12
+ authority: "external-service",
13
+ environment: "target" in result
14
+ ? {
15
+ id: `${result.browser.product}@${result.browser.revision}`,
16
+ platform: process.platform,
17
+ architecture: process.arch,
18
+ isolation: "none",
19
+ }
20
+ : null,
21
+ limitations: result.limitations,
22
+ });
23
+ const browserParameters = (input) => ({
24
+ cdp_endpoint: input.cdp_endpoint,
25
+ allowed_origins: input.allowed_origins,
26
+ ...("target_id" in input
27
+ ? {
28
+ target_id: input.target_id,
29
+ observation_ms: input.observation_ms,
30
+ include_script_sources: input.include_script_sources,
31
+ include_storage_keys: input.include_storage_keys,
32
+ limits: input.limits,
33
+ }
34
+ : { offset: input.offset, limit: input.limit }),
35
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,52 @@
1
+ import { AnalysisCapabilityUnavailableError, AnalysisProtocolError, PermissionRequiredError, } from "../domain/errors.js";
2
+ import { err, ok } from "../domain/result.js";
3
+ import { createBrowserEvidence } from "./BrowserEvidence.js";
4
+ /** Authorize and execute one policy-scoped target discovery observation. */
5
+ export const listBrowserTargets = async (browser, authority, input, options = {}) => {
6
+ const ready = await prepare(browser, authority, input, "list_browser_targets");
7
+ if (!ready.ok)
8
+ return ready;
9
+ const result = await ready.value.listTargets(input, options);
10
+ return result.ok
11
+ ? ok(createBrowserEvidence("list_browser_targets", input, result.value, ready.value.identity()))
12
+ : result;
13
+ };
14
+ /** Authorize and execute one policy-scoped passive page inspection. */
15
+ export const inspectWebPage = async (browser, authority, input, options = {}) => {
16
+ const ready = await prepare(browser, authority, input, "inspect_web_page");
17
+ if (!ready.ok)
18
+ return ready;
19
+ const result = await ready.value.inspectPage(input, options);
20
+ return result.ok
21
+ ? ok(createBrowserEvidence("inspect_web_page", input, result.value, ready.value.identity()))
22
+ : result;
23
+ };
24
+ const prepare = async (browser, authority, input, operation) => {
25
+ if (authority === undefined)
26
+ return err(new AnalysisCapabilityUnavailableError("rea-cdp-browser", operation, "browser observation permission policy is not configured"));
27
+ const authorized = await authority.authorize({
28
+ capability: "browser_observe",
29
+ roots: [],
30
+ executables: [],
31
+ environment_names: [],
32
+ origins: [input.cdp_endpoint, ...input.allowed_origins],
33
+ network: browserNetworkScope(input.allowed_origins),
34
+ mount: false,
35
+ operation_identity: `${operation}:${input.target_id ?? input.cdp_endpoint}`,
36
+ }, "read");
37
+ if (!authorized.ok)
38
+ return err(authorized.error instanceof PermissionRequiredError
39
+ ? authorized.error
40
+ : new AnalysisProtocolError(authorized.error.message, {
41
+ cause: authorized.error,
42
+ }));
43
+ return browser === undefined
44
+ ? err(new AnalysisCapabilityUnavailableError("rea-cdp-browser", operation, "browser observation provider is not configured"))
45
+ : ok(browser);
46
+ };
47
+ const browserNetworkScope = (allowedOrigins) => allowedOrigins.every((origin) => {
48
+ const hostname = new URL(origin).hostname;
49
+ return hostname === "127.0.0.1" || hostname === "[::1]";
50
+ })
51
+ ? "loopback"
52
+ : "external";
@@ -64,6 +64,13 @@ const availabilityFor = (name, kind, targetOpen, targetKind, descriptors, policy
64
64
  reason: "policy_disabled",
65
65
  remediation: "Configure or grant an evidence_read root.",
66
66
  };
67
+ if (kind === "browser-provider")
68
+ return policy.browserObservationEnabled === true
69
+ ? { reason: "available", remediation: null }
70
+ : {
71
+ reason: "policy_disabled",
72
+ remediation: "Enable browser observation and configure exact CDP endpoint and page origins.",
73
+ };
67
74
  if (kind === "session")
68
75
  return { reason: "available", remediation: null };
69
76
  if (!targetOpen)
@@ -0,0 +1,19 @@
1
+ import { realpath } from "node:fs/promises";
2
+ /** Canonicalize configured policy roots while ignoring stale missing entries. */
3
+ export const canonicalizeConfiguredRoots = async (roots) => {
4
+ const canonical = [];
5
+ for (const root of roots) {
6
+ try {
7
+ canonical.push(await realpath(root));
8
+ }
9
+ catch (cause) {
10
+ if (!isMissingPath(cause))
11
+ throw cause;
12
+ }
13
+ }
14
+ return canonical;
15
+ };
16
+ const isMissingPath = (cause) => typeof cause === "object" &&
17
+ cause !== null &&
18
+ "code" in cause &&
19
+ cause.code === "ENOENT";
@@ -47,6 +47,7 @@ export const runCrossVersionInvestigation = async (input, policy, execution) =>
47
47
  session: execution.session,
48
48
  signal: execution.signal,
49
49
  progress: execution.progress,
50
+ authorizeWorkspaceWrite: execution.authorizeWorkspaceWrite,
50
51
  });
51
52
  };
52
53
  const continueInvestigation = async (context) => {
@@ -74,6 +75,11 @@ const continueInvestigation = async (context) => {
74
75
  run.run_id === legacyIdentity.runId));
75
76
  if (context.initial !== null && existing?.status === "complete")
76
77
  return completeOutcome(context.initial, existing, true, context.session);
78
+ if (context.authorizeWorkspaceWrite !== undefined) {
79
+ const authorized = await context.authorizeWorkspaceWrite();
80
+ if (!authorized.ok)
81
+ return authorized;
82
+ }
77
83
  const pages = createInventoryEvidencePages(context.input, context.snapshots);
78
84
  if (!pages.ok)
79
85
  return pages;
@@ -0,0 +1,61 @@
1
+ import { AnalysisProtocolError, PermissionRequiredError, } from "../domain/errors.js";
2
+ import { err, ok } from "../domain/result.js";
3
+ import { PermissionPathError, } from "./PermissionAuthority.js";
4
+ /** Authorize one root-scoped operation and project policy path failures. */
5
+ export const authorizeRootPermission = async (authority, request) => {
6
+ const authorized = await authority.authorize({
7
+ capability: request.capability,
8
+ roots: request.roots,
9
+ executables: [],
10
+ environment_names: [],
11
+ network: "none",
12
+ mount: false,
13
+ operation_identity: `${request.operation}:${request.capability}:${request.roots.join(":")}`,
14
+ }, request.access);
15
+ return authorized.ok
16
+ ? ok(null)
17
+ : err(authorized.error instanceof PermissionRequiredError
18
+ ? authorized.error
19
+ : new AnalysisProtocolError(authorized.error.message, {
20
+ cause: authorized.error,
21
+ }));
22
+ };
23
+ /** Authorize an existing file read and defer write authority until requested. */
24
+ export const authorizeFileReadWithDeferredWrite = async (authority, input) => {
25
+ const read = await authority.authorize({
26
+ capability: input.readCapability,
27
+ roots: [input.path],
28
+ executables: [],
29
+ environment_names: [],
30
+ network: "none",
31
+ mount: false,
32
+ operation_identity: `${input.operation}:${input.readCapability}:${input.path}`,
33
+ }, "read");
34
+ let writeAuthorization;
35
+ if (!read.ok) {
36
+ if (!(read.error instanceof PermissionPathError) ||
37
+ read.error.reason !== "not_found")
38
+ return err(read.error instanceof PermissionRequiredError
39
+ ? read.error
40
+ : new AnalysisProtocolError(read.error.message, {
41
+ cause: read.error,
42
+ }));
43
+ writeAuthorization = authorizeRootPermission(authority, {
44
+ capability: input.writeCapability,
45
+ roots: [input.path],
46
+ access: "write",
47
+ operation: input.operation,
48
+ });
49
+ const authorized = await writeAuthorization;
50
+ if (!authorized.ok)
51
+ return authorized;
52
+ }
53
+ return ok({
54
+ authorizeWrite: () => (writeAuthorization ??= authorizeRootPermission(authority, {
55
+ capability: input.writeCapability,
56
+ roots: [input.path],
57
+ access: "write",
58
+ operation: input.operation,
59
+ })),
60
+ });
61
+ };
@@ -12,6 +12,7 @@ import { parseBinaryTarget } from "../domain/binaryTarget.js";
12
12
  import { snapshotEvidenceForQuery, snapshotMatchesTarget, } from "../domain/analysisSnapshot.js";
13
13
  import { err, ok } from "../domain/result.js";
14
14
  import { loadConfiguredPermissionAuthority } from "./PermissionConfiguration.js";
15
+ import { authorizeFileReadWithDeferredWrite, } from "./DeferredFileAuthorization.js";
15
16
  const WORKFLOW_PROVIDER = {
16
17
  id: "rea-workflow",
17
18
  name: "REA composed investigation workflow",
@@ -26,6 +27,9 @@ export const runDirectAnalysis = async (path, tool, arguments_, options = {}) =>
26
27
  logger: options.logger ?? silentLogger,
27
28
  snapshotPath: options.snapshotPath,
28
29
  signal,
30
+ ...(options.permissionAuthority === undefined
31
+ ? {}
32
+ : { permissionAuthority: options.permissionAuthority }),
29
33
  }));
30
34
  /** Execute one provider-native semantic operation with atomic provenance. */
31
35
  export const runProviderAnalysis = async (path, tool, arguments_, logger = silentLogger, signal) => withProcessCancellation(signal, (operationSignal) => runAnalysis(path, tool, arguments_, {
@@ -51,19 +55,8 @@ export const runSessionStatus = async (logger = silentLogger) => {
51
55
  await session.close();
52
56
  }
53
57
  };
54
- const authorizeAnalysis = async (authority, tool, arguments_, snapshotPath) => {
58
+ const authorizeAnalysis = async (authority, tool, arguments_) => {
55
59
  const requests = [];
56
- if (snapshotPath !== undefined) {
57
- requests.push({
58
- capability: "snapshot_read",
59
- path: snapshotPath,
60
- access: "write",
61
- }, {
62
- capability: "snapshot_write",
63
- path: snapshotPath,
64
- access: "write",
65
- });
66
- }
67
60
  if (tool === "extract_artifact" && typeof arguments_.output_root === "string")
68
61
  requests.push({
69
62
  capability: "artifact_extract",
@@ -107,17 +100,41 @@ const authorizeAnalysis = async (authority, tool, arguments_, snapshotPath) => {
107
100
  }
108
101
  return ok(null);
109
102
  };
103
+ const permissionAuthorityFor = (config, supplied) => supplied === undefined
104
+ ? loadConfiguredPermissionAuthority(config)
105
+ : Promise.resolve(ok(supplied));
106
+ const authorizeSnapshotAccess = async (authority, tool, snapshotPath) => snapshotPath === undefined
107
+ ? ok(undefined)
108
+ : authorizeFileReadWithDeferredWrite(authority, {
109
+ path: snapshotPath,
110
+ readCapability: "snapshot_read",
111
+ writeCapability: "snapshot_write",
112
+ operation: tool,
113
+ });
114
+ const authorizeDeferredWrite = (authorization) => authorization?.authorizeWrite() ?? Promise.resolve(ok(null));
115
+ const authorizeAnalysisRun = async (input) => {
116
+ const authority = await permissionAuthorityFor(input.config, input.suppliedAuthority);
117
+ if (!authority.ok)
118
+ return authority;
119
+ const operation = await authorizeAnalysis(authority.value, input.tool, input.arguments);
120
+ if (!operation.ok)
121
+ return operation;
122
+ return authorizeSnapshotAccess(authority.value, input.tool, input.snapshotPath);
123
+ };
110
124
  const runAnalysis = async (path, tool, arguments_, options) => {
111
125
  const { logger, signal, snapshotPath } = options;
112
126
  const config = parseConfig(process.env);
113
127
  if (!config.ok)
114
128
  return cliError(config.error);
115
- const permissionAuthority = await loadConfiguredPermissionAuthority(config.value);
116
- if (!permissionAuthority.ok)
117
- return cliError(permissionAuthority.error);
118
- const authorized = await authorizeAnalysis(permissionAuthority.value, tool, arguments_, snapshotPath);
119
- if (!authorized.ok)
120
- return cliError(authorized.error);
129
+ const authorization = await authorizeAnalysisRun({
130
+ config: config.value,
131
+ suppliedAuthority: options.permissionAuthority,
132
+ tool,
133
+ arguments: arguments_,
134
+ snapshotPath,
135
+ });
136
+ if (!authorization.ok)
137
+ return cliError(authorization.error);
121
138
  const unavailable = snapshotUnavailable(snapshotPath, config.value.hopperLoaderArgs);
122
139
  if (unavailable !== undefined)
123
140
  return cliError(unavailable);
@@ -135,6 +152,9 @@ const runAnalysis = async (path, tool, arguments_, options) => {
135
152
  return cliError(prepared.error);
136
153
  if (prepared.value.evidence !== undefined)
137
154
  return prepared.value.evidence;
155
+ const writable = await authorizeDeferredWrite(authorization.value);
156
+ if (!writable.ok)
157
+ return cliError(writable.error);
138
158
  const { snapshot } = prepared.value;
139
159
  const opened = await session.open(path, snapshot === undefined ? { signal } : { signal, snapshot });
140
160
  if (!opened.ok)