Compare commits

..
Author SHA1 Message Date
morluto 0f6f1600c5 fix: preserve actionable artifact diagnostics 2026-07-13 21:49:21 +00:00
morluto 092a303b0f fix(hopper): avoid blocking owned Linux shutdown 2026-07-13 21:48:31 +00:00
261 changed files with 1337 additions and 29274 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "1.5.0"
".": "1.3.0"
}
+3 -9
View File
@@ -26,15 +26,13 @@ See [docs/architecture.mermaid](docs/architecture.mermaid) for a visual architec
- `src/cli.ts`: one-shot CLI adapter for setup, diagnostics, analysis, and decompilation.
- `src/config.ts`: Zod-validated parsing of environment configuration into `AppConfig`.
- `src/domain/`: pure, side-effect-free modules. `errors.ts` (tagged error algebra), `result.ts` (`Result`/`ok`/`err`), `hopperValues.ts` (boundary parsers for Hopper JSON), `symbolAnalysis.ts` (Swift/ObjC name parsing).
- `src/contracts/`: caller-visible schemas for 33 direct, 10 enhanced, 5 native, 2 artifact, 2 browser, and 18 session tools; `enhancedInputs.ts` owns enhanced input parsing.
- `src/browser/`: loopback CDP discovery, bounded WebSocket transport, exact-origin target authorization, and passive browser observation normalization.
- `src/contracts/`: caller-visible schemas for 33 direct, 10 enhanced, 5 native, 2 artifact, and 18 session tools; `enhancedInputs.ts` owns enhanced input parsing.
- `src/hopper/`: Hopper launch and Unix-socket protocol mechanics. `BridgeLauncher.ts` spawns the Hopper app with the in-process bridge, `HopperClient.ts` correlates request/response over the socket with timeouts and cancellation, `protocol.ts` frames bridge messages.
- `bridge/hopper_bridge.py`: runs inside Hopper and adapts declared operations to Hopper's public Python API. Hopper's bundled MCP server is not used.
- `src/application/`: shared CLI/MCP session composition, setup and diagnostics, and enhanced workflows.
- `src/server/`: MCP request translation. `createServer.ts` assembles the MCP server, `registerOfficialTools.ts`/`registerEnhancedTools.ts` register each tool set, `toolResult.ts` maps `Result` values to MCP content.
- `tests/`: Vitest suite. `tests/fixtures/` holds the fake launcher and fake Hopper bridge used as production seams.
- `scripts/verify-real-hopper.mjs`: real-Hopper end-to-end verifier.
- `scripts/verify-real-browser.mjs`: real Chrome end-to-end verifier for the passive CDP provider.
- `scripts/print-mcp-config.mjs`: prints an MCP server config with absolute paths filled in (`npm run config:print -- /path/to/binary`).
## Build, Test, and Development Commands
@@ -51,8 +49,7 @@ See [docs/architecture.mermaid](docs/architecture.mermaid) for a visual architec
- `npm run jscpd`: detect duplicate code blocks.
- `npm run scan:todos`: scan for TODO, FIXME, and HACK markers.
- `npm run verify:hopper`: build and run the real-Hopper verifier with two distinct binaries.
- `npm run verify:browser`: build and run the real Chrome verifier against `REA_BROWSER_EXECUTABLE` or a platform-default Chrome-family executable.
- `npm run verify:package`: pack and test the CLI, setup transaction, skill, and 70-tool target-free MCP server in an isolated environment.
- `npm run verify:package`: pack and test the CLI, setup transaction, skill, and 68-tool target-free MCP server in an isolated environment.
- `npm run docs:generate`: generate API documentation from JSDoc comments into `docs/api/` using TypeDoc.
- `npm run config:print -- /path/to/binary`: print an MCP server config with absolute paths.
- `HOPPER_TARGET_PATH=/path/to/binary npm start`: launch Hopper and run the built stdio MCP server.
@@ -65,9 +62,6 @@ Pre-commit hooks via Husky run `oxlint` on staged files before every commit. Use
- `HOPPER_LAUNCHER_PATH` (optional): override the Hopper executable path (defaults to `/Applications/Hopper Disassembler.app/Contents/MacOS/hopper`).
- `HOPPER_TARGET_KIND` (optional, default `executable`): startup kind for `HOPPER_TARGET_PATH`; dynamic targets are classified from their paths and headers.
- `HOPPER_LOADER_ARGS_JSON` (optional): JSON array overriding derived Hopper loader arguments for supported executable targets, e.g. `["-l","Mach-O","--aarch64"]`.
- `REA_BROWSER_OBSERVE_ENABLED` (optional, default `false`): add browser observation authority to the administrator ceiling.
- `REA_BROWSER_CDP_ENDPOINTS_JSON` (optional): approved literal loopback CDP HTTP endpoints.
- `REA_BROWSER_ALLOWED_ORIGINS_JSON` (optional): exact HTTP(S) page origins approved for passive observation.
## Coding Style & Naming Conventions
@@ -75,7 +69,7 @@ Use ESM TypeScript, two-space indentation, and Prettier defaults. Keep compiler
## Testing Guidelines
Name tests `*.test.ts`. Use Vitest and production seams (`tests/fixtures/`) rather than module mocks. Domain tests assert pure behavior; adapter tests use the fake launcher/socket; MCP tests connect with the beta.3 client. Preserve the 33 direct, 10 enhanced, 5 native, 2 artifact, 2 browser, and 18 session tool inventory (70 total). Cover malformed input, cancellation, timeouts, process exit, concurrency, limits, and clean shutdown. Real Hopper and browser claims cannot be replaced by mocks; use `npm run verify:hopper` with two distinct binaries and `npm run verify:browser` with Chrome.
Name tests `*.test.ts`. Use Vitest and production seams (`tests/fixtures/`) rather than module mocks. Domain tests assert pure behavior; adapter tests use the fake launcher/socket; MCP tests connect with the beta.3 client. Preserve the 33 direct, 10 enhanced, 5 native, 2 artifact, and 18 session tool inventory (68 total). Cover malformed input, cancellation, timeouts, process exit, concurrency, limits, and clean shutdown. Real Hopper claims cannot be replaced by mocks — use `npm run verify:hopper` with two distinct binaries for end-to-end verification.
## Commit & Pull Request Guidelines
-61
View File
@@ -1,66 +1,5 @@
# Changelog
## [1.5.0](https://github.com/morluto/rea/compare/rea-agents-1.4.0...rea-agents-1.5.0) (2026-07-14)
### Features
* add passive website reverse engineering ([9986971](https://github.com/morluto/rea/commit/998697155dcc733be73e75bc77c069638942f111))
* add passive website reverse engineering ([2c1ceab](https://github.com/morluto/rea/commit/2c1ceabbf1f117daafe166e1134b47737b38e1f6))
### Bug Fixes
* **browser:** drop disallowed redirect evidence ([8481be9](https://github.com/morluto/rea/commit/8481be9c9709b13f572fefefcf8508d9e2605386))
* **browser:** scope CDP events and fail closed ([5279d77](https://github.com/morluto/rea/commit/5279d7755c74df579028ce51b4bc53b089bb2749))
* **browser:** scope workers and binary frame sizes ([dfc06cf](https://github.com/morluto/rea/commit/dfc06cf0ad4a39719ef74846cb83f05e7b6f6289))
* harden PTY events and configured roots ([ae8b1c5](https://github.com/morluto/rea/commit/ae8b1c5ca9c2d122d7fc30ebfa25e08d57ca7a51))
* harden PTY events and configured roots ([31b40f3](https://github.com/morluto/rea/commit/31b40f39e4c29a077deebefbe29e90c30e50cd0c))
* **permission:** defer cache write grants ([6a3fd5b](https://github.com/morluto/rea/commit/6a3fd5b4cec7569eebfe8a7ca6a7c0c1e3a7340d))
* **permission:** defer cache write grants ([f840e70](https://github.com/morluto/rea/commit/f840e70749ffadfe34cf8b7e464b54e8ffebd816))
* resolve triaged correctness issues ([2575b30](https://github.com/morluto/rea/commit/2575b30dfcb79c2e26ed16311f780a8723578c6a))
* resolve triaged correctness issues ([e8ed307](https://github.com/morluto/rea/commit/e8ed3073ac915e32b5ea630446ed6e14bc788119))
* resolve validation and artifact edge cases ([2468682](https://github.com/morluto/rea/commit/246868232e4196394fbc6e67cfae4d3ca214fe60))
* resolve validation and artifact edge cases ([ef7f2f9](https://github.com/morluto/rea/commit/ef7f2f93747f0a4d9f70c9fc2e252456d20843f3))
### Documentation
* add Hopper screenshot to README ([a699fdd](https://github.com/morluto/rea/commit/a699fdd6a5cc9c9812c7d78fd98b51469488866f))
* add Hopper screenshot to README ([08981a3](https://github.com/morluto/rea/commit/08981a31b5e9b76e15914c0570244158d491e047))
### Tests
* **cli:** allow cold-start integration timing ([7d5621d](https://github.com/morluto/rea/commit/7d5621dfbc4333f99846cbe52227d3b4f53ee0cd))
## [1.4.0](https://github.com/morluto/rea/compare/rea-agents-1.3.0...rea-agents-1.4.0) (2026-07-14)
### Features
* **core:** add typed policy and integrity contracts ([190efca](https://github.com/morluto/rea/commit/190efcaf1728743a02fb65a35319abdc14ea2227))
* **identity:** derive MCP surface metadata ([2722722](https://github.com/morluto/rea/commit/2722722425cb7e7f5954de95ea4641ba2f1ae03b))
* **mcp:** expose progress resources and availability ([da59299](https://github.com/morluto/rea/commit/da59299a8a83241e6912a062cf8d5744993cad9e))
* **mcp:** land policy, resources, and typed contracts ([8f79ee7](https://github.com/morluto/rea/commit/8f79ee771e362f69001a483dcbdb59f4c7b89af0))
### Bug Fixes
* **ci:** keep generated error docs with their owner ([3e60e38](https://github.com/morluto/rea/commit/3e60e38a147eafa0445e4f9935ede279b4aab688))
* **ci:** preserve stacked integration changes ([cf8e5cf](https://github.com/morluto/rea/commit/cf8e5cf5e61fe00731768107790e715958a96fc8))
* **cli:** return nonzero status for operation failures ([#121](https://github.com/morluto/rea/issues/121)) ([00c187e](https://github.com/morluto/rea/commit/00c187e32b3d2add37245a48f21185c2bd4fc22a))
* **cli:** return nonzero status for operation failures ([#122](https://github.com/morluto/rea/issues/122)) ([964620a](https://github.com/morluto/rea/commit/964620a0a108c3c671d982da37b5d4c05c4ef035))
* **hopper:** complete owned Linux shutdown ([#116](https://github.com/morluto/rea/issues/116)) ([fb51874](https://github.com/morluto/rea/commit/fb51874bb9ee1d5144c897e0de4c7e083ba4d390))
* **mcp:** preserve migrated revisions and valid links ([4149472](https://github.com/morluto/rea/commit/414947243963fed5f769a124758424dda371dcd7))
* preserve actionable artifact diagnostics ([#119](https://github.com/morluto/rea/issues/119)) ([7385220](https://github.com/morluto/rea/commit/73852202d42743fe5589fd9477f0d0991d87c060))
* **workspace:** migrate legacy integrity identities ([d092d4c](https://github.com/morluto/rea/commit/d092d4c97a51882f7cdbc0e3dd167cea309b8adf))
### Tests
* **identity:** defer live MCP integration proof ([74db3fc](https://github.com/morluto/rea/commit/74db3fcd4259f3c09fb29cc7762952320b99ae71))
## [1.3.0](https://github.com/morluto/rea/compare/rea-agents-1.2.0...rea-agents-1.3.0) (2026-07-13)
+2 -2
View File
@@ -13,7 +13,7 @@ npm run build
npm test
```
Keep dependencies flowing inward through the existing domain, contracts, provider, application, server, and adapter layers. Parse unknown values at process and protocol boundaries, model expected failures with `Result`, and preserve the 33 direct, 10 enhanced, 5 native, 2 artifact, 8 browser, 2 Electron, and 18 session tools (78 total) unless a deliberate contract change updates every verifier and snapshot.
Keep dependencies flowing inward through the existing domain, contracts, Hopper, application, server, and adapter layers. Parse unknown values at process and protocol boundaries, model expected failures with `Result`, and preserve the 33 direct, 10 enhanced, 5 native, 2 artifact, and 18 session tools (68 total) unless a deliberate contract change updates every verifier and snapshot.
Before submitting a pull request, run:
@@ -70,7 +70,7 @@ Publish the public package:
npm publish --access public
```
After npm registry propagation, verify the published CLI and connect a beta.3 MCP client to the published server to confirm all 78 tools:
After npm registry propagation, verify the published CLI and connect a beta.3 MCP client to the published server to confirm all 68 tools:
```bash
npx -y rea-agents --help
+13 -58
View File
@@ -10,27 +10,23 @@
[![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
[![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)
[![78 MCP tools](https://img.shields.io/badge/MCP_tools-78-5c4ee5?style=flat-square)](#78-tools-for-investigation)
[![68 MCP tools](https://img.shields.io/badge/MCP_tools-68-5c4ee5?style=flat-square)](#68-tools-for-investigation)
[![Node.js 22+](https://img.shields.io/badge/Node.js-22.19%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/)
[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE)
[Quick start](#quick-start) · [Current status](#current-status) · [Investigation model](#the-investigation-model) · [78 tools](#78-tools-for-investigation) · [Roadmap](#roadmap) · [How it works](#how-it-works)
[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)
<br />
<code>npm install --global rea-agents && rea setup</code>
<br />
<img src="docs/assets/rea-hopper-analysis.png" alt="REA launching its analysis bridge inside Hopper while inspecting a native binary" width="1200" />
</div>
---
See a feature in an app that you want in your own product? Give the app to your agent—even without its source code. With REA, the agent can investigate the feature, explain how it works, show its evidence, and build a version adapted to your stack and requirements.
REA gives agents one consistent way to investigate software. Today that includes deep native analysis through Hopper, complete function dossiers, reproducible Evidence v2 records, controlled process capture, passive website and Electron observation, and bounded JavaScript/source-map reconstruction. The longer-term toolkit extends the same agent workflow to APIs, protocols, mobile artifacts, firmware, richer runtime behavior, and differences between versions.
REA gives agents one consistent way to investigate software. Today that includes deep native analysis through Hopper, complete function dossiers, reproducible Evidence v2 records, and controlled process capture. The longer-term toolkit extends the same agent workflow to packaged apps, JavaScript bundles, websites, APIs, protocols, mobile artifacts, firmware, runtime behavior, and differences between versions.
Reverse engineering normally makes the operator choose a tool, learn its API, move evidence between programs, and decide what to inspect next. REA gives that work to the agent through commands, skills, structured results, and repeatable investigation workflows.
@@ -294,17 +290,15 @@ REA handles the app analysis in steps 1–5. The agent performs step 6 with its
- Analyze Swift and Objective-C metadata without manually untangling every mangled symbol.
- Leave names, comments, and bookmarks in Hopper so human and agent analysis reinforce each other.
## 78 tools for investigation
## 68 tools for investigation
| Tool family | Count | Examples |
| ------------------------- | ----: | -------------------------------------------------------------------------------------------------------------------------------- |
| Native inspection | 33 | procedures, pseudocode, assembly, strings, names, segments, callers, callees, xrefs, annotations |
| Investigation workflows | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `trace_feature`, call graphs, Swift and Objective-C discovery |
| Native macOS utilities | 5 | Mach-O metadata, code signatures, plists, architectures, Swift demangling; Hopper-free and provenance-bearing |
| Artifact graph | 2 | deterministic directory, ZIP/APK/IPA, and ASAR inventory; explicitly selected extraction into an absent owned tree |
| Browser observation | 8 | exact-origin CDP capture, bundle and source-map analysis, WebMCP discovery, session timelines, capture diff, and visual evidence |
| Electron observation | 2 | canonical-root-confined file-page discovery and passive DOM, resource, and optionally approved script-source inspection |
| Workspace and observation | 18 | target lifecycle, Evidence v2 bundles, process/artifact/function comparison, evidence-linked residual-unknown lifecycle |
| Tool family | Count | Examples |
| ------------------------- | ----: | ----------------------------------------------------------------------------------------------------------------------- |
| Native inspection | 33 | procedures, pseudocode, assembly, strings, names, segments, callers, callees, xrefs, annotations |
| Investigation workflows | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `trace_feature`, call graphs, Swift and Objective-C discovery |
| Native macOS utilities | 5 | Mach-O metadata, code signatures, plists, architectures, Swift demangling; Hopper-free and provenance-bearing |
| Artifact graph | 2 | deterministic directory, ZIP/APK/IPA, and ASAR inventory; explicitly selected extraction into an absent owned tree |
| Workspace and observation | 18 | target lifecycle, Evidence v2 bundles, process/artifact/function comparison, evidence-linked residual-unknown lifecycle |
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.
@@ -313,8 +307,6 @@ The public interface describes what the agent is trying to learn. Providers deci
REA is already useful for native application investigation on macOS:
- Open Mach-O, ELF, PE, `.app`, ZIP, APK, IPA, ASAR, plist, JavaScript, source-map, and Hopper database targets.
- Attach to a user-owned Chrome-family browser over a configured loopback CDP endpoint; capture exact-origin web structure, safe metadata, approved value-free payload shapes, bundle/source-map evidence, WebMCP declarations, user-action timelines, capture diffs, and explicitly approved screenshots without navigation or JavaScript evaluation.
- Inspect Electron `file://` renderer pages through a separate canonical-root permission boundary without invoking Electron APIs; script contents remain separately approved and byte bounded.
- 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.
- Build bounded function dossiers with pseudocode, assembly, CFG edges, comments, calls, references, strings, and names.
- Search and trace features across symbols, strings, metadata, references, and call paths.
@@ -335,27 +327,12 @@ REA is already useful for native application investigation on macOS:
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.
### Website observation with CDP
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:
```bash
export REA_BROWSER_OBSERVE_ENABLED=true
export REA_BROWSER_CDP_ENDPOINTS_JSON='["http://127.0.0.1:9222"]'
export REA_BROWSER_ALLOWED_ORIGINS_JSON='["http://127.0.0.1:3000"]'
rea list-browser-targets http://127.0.0.1:9222 --approved --json
rea inspect-web-page http://127.0.0.1:9222 TARGET_ID --approved --json
```
All eight browser tools expose the same Evidence v2 contracts over CLI and MCP. Inspection is passive: REA does not evaluate page JavaScript, navigate, click, close the page, or close the browser. Query values, credentials, cookies, authorization headers, storage values, and raw JSON or WebSocket values are never retained. Separately approved captures can retain bounded redacted console primitives, value-free JSON/WebSocket shapes, script sources, accessibility text, or screenshot pixels. 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.
## Roadmap
REA is growing into a toolkit for understanding software across static artifacts and observed behavior. The next capability families are:
1. **Artifact decomposition** — DMG, ASAR, ZIP, packages, universal-binary slices, application resources, embedded frameworks, mobile packages, and artifact graphs.
2. **Web and Electron investigation** — extend the shipped passive CDP, bundle, visual-diff, and file-page observation with approved interaction, controlled replay, Electron IPC observation, semantic UI differences, and deeper JavaScript reconstruction.
2. **Web and Electron investigation** — Playwright/CDP capture of DOM, accessibility trees, screenshots, storage, console, IPC, HTTP, WebSocket, routes, and visual or structural differences.
3. **Deterministic behavior harnesses** — stronger process-tree ownership, protocol fixtures, network policy, filesystem tracing, signals, reconnects, and cross-version comparison.
4. **JavaScript and source recovery** — bundle indexing, AST/module reconstruction, source-map discovery, historical-source matching, and CodeDB-backed cross-references.
5. **Runtime observation** — approval-gated LLDB, Frida, system logs, process and filesystem observers, and native API tracing.
@@ -400,9 +377,8 @@ flowchart LR
Router --> Hopper["Hopper provider"]
Router --> Native["Native macOS provider"]
Router --> Artifact["Artifact graph provider"]
Router --> Browser["Browser CDP provider"]
Router --> Process["Process capture provider"]
Router -. roadmap .-> More["Dynamic and additional<br/>static providers"]
Router -. roadmap .-> More["Browser, dynamic,<br/>and additional static providers"]
Hopper --> Target["Target software"]
Process --> Target
Native --> Target
@@ -444,27 +420,6 @@ rea mcp
REA accepts a Mac `.app` folder directly. If an agent cannot find an app by name, tell it where the app is installed.
### CLI exit status
| Status | Meaning |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `0` | The requested operation completed. Truthful unknowns, warnings, and bounded or truncated evidence remain successful results. |
| `1` | Arguments, policy, permission, provider analysis, integrity checking, cancellation, timeout, setup, diagnostics, update, uninstall, output encoding, or output writing prevented completion. Structured output identifies the failure category when REA could encode it. |
| `128 + N` | The process ended from signal `N`, where the shell or runtime preserves the conventional signal-derived status. |
`setup` returns `1` for `planned`, `needs_confirmation`, or `needs_human`
because configuration is not ready; rerun it after approval or remediation.
`doctor` returns `1` when any check is unhealthy. Output format, full envelopes,
filters, and token controls never change the operation status.
When REA feeds a shell pipeline, enable `pipefail` so a downstream formatter
cannot hide its failure:
```bash
set -o pipefail
rea inventory-artifact ./app.asar --json | jq . > inventory.json
```
## Current Hopper provider
REA starts Hopper when needed; Hopper does not need to be running first. Hopper's launcher internally activates the application, so opening a target may bring Hopper to the foreground. REA asks macOS to start Hopper hidden and in the background when possible, but cannot guarantee that it will remain behind the current application.
+10 -12
View File
@@ -10,11 +10,11 @@
[![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
[![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)
[![78 MCP tools](https://img.shields.io/badge/MCP_tools-78-5c4ee5?style=flat-square)](#منصة-من-78-أداة)
[![68 MCP tools](https://img.shields.io/badge/MCP_tools-68-5c4ee5?style=flat-square)](#منصة-من-68-أداة)
[![Node.js 22+](https://img.shields.io/badge/Node.js-22.19%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/)
[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE)
[البدء السريع](#البدء-السريع) · [من الملف التنفيذي إلى السلوك](#من-الملف-التنفيذي-إلى-السلوك) · [78 أداة](#منصة-من-78-أداة) · [كيف يعمل](#كيف-يعمل) · [الأسئلة الشائعة](#الأسئلة-الشائعة)
[البدء السريع](#البدء-السريع) · [من الملف التنفيذي إلى السلوك](#من-الملف-التنفيذي-إلى-السلوك) · [68 أداة](#منصة-من-68-أداة) · [كيف يعمل](#كيف-يعمل) · [الأسئلة الشائعة](#الأسئلة-الشائعة)
<br />
@@ -155,17 +155,15 @@ rea uninstall --purge-data
افحص التحقق من الإدخال، والتشفير، والصلاحيات، والتخزين، ومسارات الأخطاء من دون رفع الملف التنفيذي إلى خدمة بعيدة.
## منصة من 78 أداة
## منصة من 68 أداة
| عائلة الأدوات | العدد | أمثلة |
| --------------------- | ----: | ---------------------------------------------------------------------------------------- |
| فحص الملفات التنفيذية | 33 | الدوال، والشيفرة شبه المصدرية، والتعليمات، والسلاسل، والأسماء، والمراجع، والتعليقات |
| التحليل المركب | 10 | النظرة العامة، وفك الترجمة الدفعي، ومخططات الاستدعاء، والمراجع، واكتشاف الأنواع |
| أدوات macOS الأصلية | 5 | بيانات Mach-O والتوقيعات وملفات plist والمعماريات وفك رموز Swift دون تشغيل Hopper |
| رسم القطع الأثرية | 2 | جرد حتمي للدلائل وZIP/APK/IPA وASAR واستخراج معاملاتي محدد صراحة |
| مراقبة المتصفح | 8 | التقاط CDP محدود الأصل وتحليل الحزم وخرائط المصدر واكتشاف WebMCP والفروق والأدلة المرئية |
| مراقبة Electron | 2 | اكتشاف صفحات الملفات ضمن جذور canonical وفحص DOM والموارد والمصادر المعتمدة |
| جلسة الملف التنفيذي | 18 | فتح الأهداف وحزم الأدلة ومقارنة العمليات والقطع والدوال وسجل المجهولات المتبقية |
| عائلة الأدوات | العدد | أمثلة |
| --------------------- | ----: | ----------------------------------------------------------------------------------- |
| فحص الملفات التنفيذية | 33 | الدوال، والشيفرة شبه المصدرية، والتعليمات، والسلاسل، والأسماء، والمراجع، والتعليقات |
| التحليل المركب | 10 | النظرة العامة، وفك الترجمة الدفعي، ومخططات الاستدعاء، والمراجع، واكتشاف الأنواع |
| أدوات macOS الأصلية | 5 | بيانات Mach-O والتوقيعات وملفات plist والمعماريات وفك رموز Swift دون تشغيل Hopper |
| رسم القطع الأثرية | 2 | جرد حتمي للدلائل وZIP/APK/IPA وASAR واستخراج معاملاتي محدد صراحة |
| جلسة الملف التنفيذي | 18 | فتح الأهداف وحزم الأدلة ومقارنة العمليات والقطع والدوال وسجل المجهولات المتبقية |
## استخدام REA مع وكلاء برمجة آخرين
+3 -5
View File
@@ -10,11 +10,11 @@
[![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
[![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)
[![78 MCP tools](https://img.shields.io/badge/MCP_tools-78-5c4ee5?style=flat-square)](#78-ツールのワークベンチ)
[![68 MCP tools](https://img.shields.io/badge/MCP_tools-68-5c4ee5?style=flat-square)](#68-ツールのワークベンチ)
[![Node.js 22+](https://img.shields.io/badge/Node.js-22.19%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/)
[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE)
[クイックスタート](#クイックスタート) · [バイナリから動作へ](#バイナリから動作へ) · [78 ツール](#78-ツールのワークベンチ) · [仕組み](#仕組み) · [FAQ](#faq)
[クイックスタート](#クイックスタート) · [バイナリから動作へ](#バイナリから動作へ) · [68 ツール](#68-ツールのワークベンチ) · [仕組み](#仕組み) · [FAQ](#faq)
<br />
@@ -145,7 +145,7 @@ REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェ
- Swift / Objective-C のメタデータを解析する。
- Hopper に名前、コメント、ブックマークを残し、人間とエージェントの調査を共有する。
## 78 ツールのワークベンチ
## 68 ツールのワークベンチ
| ツール群 | 数 | 例 |
| ---------------------- | --: | ------------------------------------------------------------------------------------------------------------------- |
@@ -153,8 +153,6 @@ REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェ
| 合成解析 | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift / ObjC 検出 |
| macOS ネイティブ | 5 | Mach-O メタデータ、署名、plist、アーキテクチャ、Swift デマングル。Hopper 起動不要 |
| アーティファクトグラフ | 2 | ディレクトリ、ZIP/APK/IPA、ASAR の決定的一覧と明示選択されたトランザクション抽出 |
| ブラウザ観察 | 8 | origin 限定 CDP 取得、bundle/source map 解析、WebMCP 検出、セッション、capture diff、視覚証拠 |
| Electron 観察 | 2 | canonical ファイルルート内のページ検出と受動的 DOM、リソース、承認済みスクリプトソース調査 |
| バイナリセッション | 18 | `open_binary`、`binary_session`、証拠バンドル、プロセス/成果物/関数比較、残余未知レジストリ |
## 他のコーディングエージェントで使う
+3 -5
View File
@@ -10,11 +10,11 @@
[![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
[![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)
[![78 MCP tools](https://img.shields.io/badge/MCP_tools-78-5c4ee5?style=flat-square)](#78개-도구로-구성된-워크벤치)
[![68 MCP tools](https://img.shields.io/badge/MCP_tools-68-5c4ee5?style=flat-square)](#68개-도구로-구성된-워크벤치)
[![Node.js 22+](https://img.shields.io/badge/Node.js-22.19%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/)
[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE)
[빠른 시작](#빠른-시작) · [바이너리에서 동작까지](#바이너리에서-동작까지) · [78개 도구](#78개-도구로-구성된-워크벤치) · [작동 방식](#작동-방식) · [FAQ](#faq)
[빠른 시작](#빠른-시작) · [바이너리에서 동작까지](#바이너리에서-동작까지) · [68개 도구](#68개-도구로-구성된-워크벤치) · [작동 방식](#작동-방식) · [FAQ](#faq)
<br />
@@ -145,7 +145,7 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이
- Swift 및 Objective-C 메타데이터를 분석합니다.
- Hopper에 이름, 주석, 북마크를 남겨 사람과 에이전트의 분석을 연결합니다.
## 78개 도구로 구성된 워크벤치
## 68개 도구로 구성된 워크벤치
| 도구 그룹 | 수 | 예시 |
| ------------------- | --: | -------------------------------------------------------------------------------------------------------------------- |
@@ -153,8 +153,6 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이
| 합성 분석 | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift 및 ObjC 탐색 |
| macOS 네이티브 도구 | 5 | Mach-O 메타데이터, 코드 서명, plist, 아키텍처, Swift 디맹글링. Hopper 실행 불필요 |
| 아티팩트 그래프 | 2 | 디렉터리, ZIP/APK/IPA, ASAR 결정적 인벤토리와 명시적으로 선택한 트랜잭션 추출 |
| 브라우저 관찰 | 8 | origin 제한 CDP 캡처, bundle/source map 분석, WebMCP 검색, 세션 타임라인, capture diff 및 시각 증거 |
| Electron 관찰 | 2 | canonical 파일 루트 내 페이지 검색과 수동 DOM, 리소스 및 별도 승인된 스크립트 소스 조사 |
| 바이너리 세션 | 18 | `open_binary`, `binary_session`, 증거 번들, 프로세스·아티팩트·함수 비교, 잔여 미지수 레지스트리 |
## 다른 코딩 에이전트에서 사용하기
+3 -5
View File
@@ -10,11 +10,11 @@
[![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
[![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)
[![78 MCP tools](https://img.shields.io/badge/MCP_tools-78-5c4ee5?style=flat-square)](#78-个工具组成的工作台)
[![68 MCP tools](https://img.shields.io/badge/MCP_tools-68-5c4ee5?style=flat-square)](#68-个工具组成的工作台)
[![Node.js 22+](https://img.shields.io/badge/Node.js-22.19%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/)
[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE)
[快速开始](#快速开始) · [从二进制到行为](#从二进制到行为) · [78 个工具](#78-个工具组成的工作台) · [工作原理](#工作原理) · [常见问题](#常见问题)
[快速开始](#快速开始) · [从二进制到行为](#从二进制到行为) · [68 个工具](#68-个工具组成的工作台) · [工作原理](#工作原理) · [常见问题](#常见问题)
<br />
@@ -145,7 +145,7 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
- 分析 Swift 和 Objective-C 元数据。
- 在 Hopper 中留下名称、注释与书签,使人与智能体的分析互相增强。
## 78 个工具组成的工作台
## 68 个工具组成的工作台
| 工具类别 | 数量 | 示例 |
| -------------- | ---: | -------------------------------------------------------------------------------------------------------------------- |
@@ -153,8 +153,6 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
| 组合分析 | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`、Swift 与 ObjC 发现 |
| macOS 原生工具 | 5 | Mach-O 元数据、代码签名、plist、架构与 Swift 符号还原,无需启动 Hopper |
| 制品图谱 | 2 | 确定性目录、ZIP/APK/IPA 与 ASAR 清单,以及显式选择的事务式提取 |
| 浏览器观察 | 8 | 精确来源 CDP 抓取、bundle/source map 分析、WebMCP 发现、会话时间线、capture diff 与视觉证据 |
| Electron 观察 | 2 | 限定 canonical 文件根的页面发现,以及被动 DOM、资源与另行批准的脚本源码检查 |
| 二进制会话 | 18 | `open_binary`、`binary_session`、证据包、进程、制品与函数比较、残余未知项注册表 |
## 与其他编程智能体一起使用
+3 -5
View File
@@ -628,13 +628,13 @@ def _dispatch(method, params):
global _selected_document
if method == "health":
return {"name": "REA Hopper bridge", "version": "1.0.0", "run_id": REA_RUN_ID}
if method in ("shutdown", "shutdown_document"):
if method == "shutdown":
document = _session_document()
if document is None:
return {"shutdown": True, "analysis_stopped": True, "document_closed": True}
if document.backgroundProcessActive():
document.requestBackgroundProcessStop()
if method == "shutdown" and REA_OWNS_PROCESS_LIFETIME:
if REA_OWNS_PROCESS_LIFETIME:
return {
"shutdown": True,
"analysis_stopped": not document.backgroundProcessActive(),
@@ -813,9 +813,7 @@ def _serve_connection(connection):
if not isinstance(request["token"], str) or not hmac.compare_digest(request["token"], REA_TOKEN):
raise PermissionError("Invalid bridge capability")
result = _dispatch(request["method"], request["params"])
should_stop = request["method"] == "shutdown_document" or (
request["method"] == "shutdown" and not result.get("cleanup_required", False)
)
should_stop = request["method"] == "shutdown"
response = {"id": request_id, "result": _json_safe(result)}
except Exception as error:
response = {"id": request_id if isinstance(request_id, int) else 0, "error": {"code": -32000, "message": str(error)[:512], "type": _diagnostic_type(error)}}
+3 -10
View File
@@ -20,12 +20,8 @@ graph TD
HOPPER["src/hopper/<br/>Hopper launch & Unix-socket protocol<br/>BridgeLauncher, HopperClient, protocol"]
end
subgraph "Browser Provider Layer"
BROWSER["src/browser/<br/>Loopback CDP transport<br/>exact-origin passive observation"]
end
subgraph "Contracts Layer"
CONTRACTS["src/contracts/<br/>Caller-visible tool schemas<br/>78 analysis, browser, Electron, and session tools"]
CONTRACTS["src/contracts/<br/>Caller-visible tool schemas<br/>68 direct, enhanced, native, artifact, and session tools"]
end
subgraph "Domain Layer"
@@ -33,11 +29,11 @@ graph TD
end
subgraph "Configuration"
CONFIG["src/config.ts<br/>Zod-validated env parsing<br/>provider settings, origins, and approved roots"]
CONFIG["src/config.ts<br/>Zod-validated env parsing<br/>provider settings and approved roots"]
end
subgraph "Tests"
TESTS["tests/<br/>Vitest suite<br/>fixtures/ holds fake launcher, bridge, and CDP seams"]
TESTS["tests/<br/>Vitest suite<br/>fixtures/ holds fake launcher & bridge seams"]
end
DISPATCH --> CLI
@@ -51,12 +47,9 @@ graph TD
SERVER --> CONTRACTS
SERVER --> APP
APP --> HOPPER
APP --> BROWSER
HOPPER --> BRIDGE
APP --> DOMAIN
CONTRACTS --> DOMAIN
HOPPER --> DOMAIN
BROWSER --> DOMAIN
TESTS -.-> MAIN
TESTS -.-> HOPPER
TESTS -.-> BROWSER
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

-160
View File
@@ -1,160 +0,0 @@
# Website observation with CDP
REA can attach to a user-owned Chrome-family browser through the Chrome DevTools Protocol (CDP) and produce bounded Evidence v2 about an existing page. This is a passive reverse-engineering capability, not a general browser automation or remote-control surface.
## Shipped surfaces
- `list_browser_targets` / `rea list-browser-targets` discovers page targets whose current URL matches an approved exact origin.
- `inspect_web_page` / `rea inspect-web-page` captures bounded DOM structure, accessibility nodes, scripts, resources, safe response/DOM metadata, attach-window network and console metadata, WebSocket frame sizes, workers, quota, and optionally storage key names or script sources.
- `analyze_web_bundle` / `rea analyze-web-bundle` parses approved script artifacts without execution and derives chunk edges, route and endpoint candidates, vendor fingerprints, static WebMCP declarations, and optionally approved source-map/original-source evidence.
- `observe_web_session` / `rea observe-web-session` arms a bounded window for an external user action and records ordered reload, SPA navigation, redirect, failure, lifecycle, and target-termination metadata.
- `discover_webmcp_tools` / `rea discover-webmcp-tools` uses the experimental CDP WebMCP domain to inventory page registrations. REA never exposes `WebMCP.invokeTool`.
- `compare_web_captures` / `rea compare-web-captures` compares stable DOM, script, resource, network, metadata, and optional WebMCP identities without treating incomplete absence as equivalence.
- `capture_web_screenshot` / `rea capture-web-screenshot` returns an explicitly approved, bounded, content-addressed visible-viewport PNG.
- `compare_web_screenshots` / `rea compare-web-screenshots` performs bounded local PNG pixel comparison without OCR or external services.
- Every surface has equivalent CLI and MCP contracts and returns Evidence v2 provenance.
- MCP session results are retained as `rea://evidence/{evidenceId}` resources. One-shot CLI output is not retained by a long-lived REA session.
Electron `file://` pages use a separate provider, permission capability, and root model; see [electron-observation.md](electron-observation.md).
## Start a browser
Start a separate browser profile with an explicit debugging port. The exact executable name varies by platform and installation:
```bash
google-chrome \
--remote-debugging-port=9222 \
--user-data-dir=/tmp/rea-browser-profile \
http://127.0.0.1:3000
```
REA does not launch, own, or terminate this browser. Use a dedicated profile and stop it yourself when the investigation is complete. Do not expose the debugging port on a non-loopback interface.
## Configure authority
Browser observation is fail-closed by default. Configure the administrator ceiling before starting REA:
```bash
export REA_BROWSER_OBSERVE_ENABLED=true
export REA_BROWSER_CDP_ENDPOINTS_JSON='["http://127.0.0.1:9222"]'
export REA_BROWSER_ALLOWED_ORIGINS_JSON='["http://127.0.0.1:3000"]'
```
`REA_BROWSER_CDP_ENDPOINTS_JSON` accepts at most 16 literal `http://127.0.0.1:PORT` or `http://[::1]:PORT` endpoints. `localhost`, private-LAN addresses, HTTPS endpoints, credentials, paths, queries, fragments, and implicit ports are rejected.
`REA_BROWSER_ALLOWED_ORIGINS_JSON` accepts at most 32 exact HTTP(S) origins. Paths, credentials, queries, fragments, and wildcards are rejected. An input must stay within both this administrator ceiling and any active project/session/once grant.
Configuration is reloadable through the existing `SIGHUP` permission-policy path. A request still requires `approved: true`; configuration alone does not make a tool call implicit.
Inspect the exact effective scope without contacting the browser:
```bash
rea policy explain browser_observe \
--origins http://127.0.0.1:9222 \
--origins http://127.0.0.1:3000 \
--network loopback \
--json
```
## CLI workflow
Configured origins are used by default. `--allowed-origins` can request a narrower set that still fits the configured ceiling.
```bash
rea list-browser-targets http://127.0.0.1:9222 --approved --json
rea inspect-web-page http://127.0.0.1:9222 TARGET_ID \
--approved \
--observation-ms 1000 \
--json
```
Static bundle analysis and a user-driven observation window are separate operations:
```bash
rea analyze-web-bundle http://127.0.0.1:9222 TARGET_ID \
--approved --source-capture-approved --json
rea observe-web-session http://127.0.0.1:9222 TARGET_ID \
--approved --observation-ms 10000 --json
rea discover-webmcp-tools http://127.0.0.1:9222 TARGET_ID \
--approved --json
```
Accessibility names/descriptions, console primitive text, JSON body shapes, WebSocket shapes, script content, and storage key names require separate opt-ins. Console/body/WebSocket capture additionally requires its matching approval flag:
```bash
rea inspect-web-page http://127.0.0.1:9222 TARGET_ID \
--approved \
--include-accessibility-text \
--include-console-text --console-text-approved \
--include-json-body-shapes --json-body-schema-approved \
--include-websocket-shapes --websocket-shape-approved \
--include-script-sources \
--include-storage-keys \
--json
```
Accessibility text and script content may contain application secrets and become part of the returned Evidence. Accessibility and console text have independent per-field and aggregate UTF-8 byte limits; credential-shaped console substrings are redacted. JSON and WebSocket captures retain paths, types, counts, and truncation only, never values or examples. Storage values remain redacted even when key-name capture is approved.
Screenshot pixels require both `--approved` and `--screenshot-approved`:
```bash
rea capture-web-screenshot http://127.0.0.1:9222 TARGET_ID \
--approved --screenshot-approved --json
```
## MCP input
```json
{
"cdp_endpoint": "http://127.0.0.1:9222",
"allowed_origins": ["http://127.0.0.1:3000"],
"target_id": "TARGET_ID_FROM_LIST_BROWSER_TARGETS",
"approved": true,
"observation_ms": 500,
"include_accessibility_text": false,
"include_console_text": false,
"console_text_approved": false,
"include_json_body_shapes": false,
"json_body_schema_approved": false,
"include_websocket_shapes": false,
"websocket_shape_approved": false,
"include_script_sources": false,
"include_storage_keys": false
}
```
Call `list_browser_targets` first because target IDs are browser-instance-specific. REA rechecks the selected target's current type and origin immediately before attaching.
REA establishes an authorized main-frame boundary before enabling Runtime, Debugger, or Network observation, rechecks it before document capture, and validates it again afterward. If the main frame navigates during final capture, REA discards the mixed result and returns `target_changed`.
## Data minimization
REA removes sensitive values before normalized event data is retained:
- URLs retain a bounded path and at most 256 bounded query parameter names, replace every retained query value with `[REDACTED]`, and remove credentials and fragments.
- DOM snapshots retain node types, node names, value lengths, and attribute names, but not text or attribute values.
- Accessibility structure and roles are retained by default, but names and descriptions require `include_accessibility_text: true` and remain byte-bounded.
- Network observations retain method, status, MIME type, size, type, initiator stack location, and redacted URLs. Headers are discarded after an allowlisted projection of length/encoding, structured CSP/Link/policy fields, and untrusted agent hints. Cookies and authorization headers are never retained.
- Request/response bodies are not requested or parsed by default. With independent approval, only allowed-origin JSON media types are read within per-body and aggregate budgets, converted immediately to value-free property paths/types, and discarded. `Network.getResponseBody` is never sent without that approval.
- Console observations with an approved stack source retain call type, argument types, timestamp, and redacted source location. With independent approval, only already-delivered primitive values are retained after credential redaction and byte bounds; objects, getters, and remote properties are never expanded.
- WebSocket observations retain direction, opcode, and payload byte length. With independent approval, bounded text frames are classified as text or value-free JSON shape; binary bytes, hashes, prefixes, and raw frames are never retained.
- Storage observations always redact values. Key names, IndexedDB names, and cache names are omitted unless explicitly requested.
- Script metadata is included only when CDP supplies a URL on an allowed origin. Stable keys exclude transient CDP script IDs, and exact transient raw URLs are used only during script/resource reconciliation. URL-less scripts are excluded because their origin cannot be established. Source content is omitted unless explicitly requested and becomes a self-verifying `rea://web-content/sha256/...` artifact subject to per-script and aggregate byte limits.
Cross-origin frames, resources, scripts, events, and workers are excluded unless their exact origins are also approved. Excluded target details are counted without being exposed.
## Completeness and limits
The default observation window is 500 ms and the maximum is 10 seconds. Frames (200), DOM nodes (2,000), accessibility nodes (2,000), scripts (200), resources (2,000), workers (500), and each storage key/name inventory (1,000) have conservative defaults and caller-visible hard bounds. Script source bytes, network events, console events, and WebSocket events are bounded separately. The CLI exposes the same limits as kebab-case options such as `--max-frames` and `--max-storage-keys`.
Every result distinguishes `complete_within_window`, `policy_filtered`, `attach_limited`, and `truncated` coverage. It names affected sections and reports sparse excluded-item and per-event-type dropped counts rather than implying exhaustive capture. Disallowed-origin entries are filtered before collection-limit accounting, so they cannot consume an approved origin's output budget.
CDP discovery, WebSocket connection, and each command have a 5-second timeout. Version discovery is capped at 64 KiB, target discovery at 2 MiB and 1,000 targets, each WebSocket message at 16 MiB, and correlated pending commands at 128. Malformed unsolicited protocol events poison the connection so later commands fail closed.
Network and console coverage starts only after REA attaches and enables the relevant CDP domains. `prior_activity_available` is always `false`; absence from these arrays is not evidence that an event never occurred. Source maps are not fetched by inspection. Bundle analysis can fetch them only after separate approval, with exact-origin redirect checks, no credentials/cookies/referrer, per-map and aggregate byte limits, a total map-count limit, v3 validation, and bounded generated/original mappings. Source-map results report requested, processed, and dropped counts rather than hiding budget truncation.
## Non-goals and threat model
The browser provider deliberately does not expose generic CDP commands, `Runtime.evaluate`, WebMCP invocation, navigation, input, downloads, page closure, or browser closure. Screenshot capture is the sole pixel surface and requires separate literal approval. REA disables the domains it enabled, detaches the target session, and closes only its own WebSocket.
This feature is not a browser sandbox or network containment mechanism. The attached page and browser continue running with their existing privileges and may make external requests independently of REA. CDP gives deep access to the selected browser profile, so use a dedicated profile, approve only origins you intend to inspect, and treat other same-user processes as outside this boundary.
-35
View File
@@ -1,35 +0,0 @@
# Electron file-page observation
REA can attach to a user-owned Electron/Chromium CDP endpoint and inspect existing `file://` renderer pages without evaluating JavaScript or invoking Electron APIs. Electron observation is separate from website observation because filesystem roots, not HTTP origins, define its authority.
## Configure authority
The capability is disabled by default:
```bash
export REA_ELECTRON_OBSERVE_ENABLED=true
export REA_ELECTRON_CDP_ENDPOINTS_JSON='["http://127.0.0.1:9223"]'
export REA_ELECTRON_FILE_ROOTS_JSON='["/Applications/Example.app/Contents/Resources"]'
```
Endpoints accept only explicit-port loopback HTTP URLs. Roots are canonicalized by the shared permission authority. Each target, frame, script, and resource is independently converted from a hostless `file://` URL to a real path and checked after symlink resolution. UNC hosts, percent-encoded path separators, nonexistent paths, and root escapes are rejected.
## Workflow
```bash
rea list-electron-targets http://127.0.0.1:9223 --approved --json
rea inspect-electron-page http://127.0.0.1:9223 TARGET_ID \
--approved --observation-ms 100 --json
```
Script content is excluded by default. Capturing it requires both flags and remains subject to per-script and aggregate byte budgets:
```bash
rea inspect-electron-page http://127.0.0.1:9223 TARGET_ID \
--approved \
--include-script-sources \
--source-capture-approved \
--json
```
The normalized result contains canonical local paths, bounded frame and DOM structure, resource metadata, stable script/resource identities, explicit completeness, and content-addressed approved source artifacts. It does not retain DOM values, execute renderer code, navigate, click, invoke Electron IPC, close a target, or terminate the application.
-837
View File
@@ -1,837 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"oneOf": [
{
"type": "object",
"properties": {
"code": { "type": "string", "const": "invalid_request" },
"category": {
"type": "string",
"enum": [
"invalid_input",
"permission_required",
"unsupported_provider",
"integrity_mismatch",
"truncated",
"cancelled",
"timeout",
"unavailable",
"execution_failure"
]
},
"message": { "type": "string", "minLength": 1 },
"retryable": { "type": "boolean" },
"remediation": {
"type": "object",
"properties": {
"action": { "type": "string", "minLength": 1 },
"restart_required": { "type": "boolean" },
"elicitation_supported": { "type": "boolean" }
},
"required": ["action", "restart_required"],
"additionalProperties": false
},
"details": {
"type": "object",
"propertyNames": { "type": "string" },
"additionalProperties": { "$ref": "#/$defs/__schema0" }
}
},
"required": ["code", "category", "message", "retryable", "remediation"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"code": { "type": "string", "const": "unreadable_output" },
"category": {
"type": "string",
"enum": [
"invalid_input",
"permission_required",
"unsupported_provider",
"integrity_mismatch",
"truncated",
"cancelled",
"timeout",
"unavailable",
"execution_failure"
]
},
"message": { "type": "string", "minLength": 1 },
"retryable": { "type": "boolean" },
"remediation": {
"type": "object",
"properties": {
"action": { "type": "string", "minLength": 1 },
"restart_required": { "type": "boolean" },
"elicitation_supported": { "type": "boolean" }
},
"required": ["action", "restart_required"],
"additionalProperties": false
},
"details": {
"type": "object",
"propertyNames": { "type": "string" },
"additionalProperties": { "$ref": "#/$defs/__schema0" }
}
},
"required": ["code", "category", "message", "retryable", "remediation"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"code": { "type": "string", "const": "capability_unavailable" },
"category": {
"type": "string",
"enum": [
"invalid_input",
"permission_required",
"unsupported_provider",
"integrity_mismatch",
"truncated",
"cancelled",
"timeout",
"unavailable",
"execution_failure"
]
},
"message": { "type": "string", "minLength": 1 },
"retryable": { "type": "boolean" },
"remediation": {
"type": "object",
"properties": {
"action": { "type": "string", "minLength": 1 },
"restart_required": { "type": "boolean" },
"elicitation_supported": { "type": "boolean" }
},
"required": ["action", "restart_required"],
"additionalProperties": false
},
"details": {
"type": "object",
"propertyNames": { "type": "string" },
"additionalProperties": { "$ref": "#/$defs/__schema0" }
}
},
"required": ["code", "category", "message", "retryable", "remediation"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"code": { "type": "string", "const": "provider_unavailable" },
"category": {
"type": "string",
"enum": [
"invalid_input",
"permission_required",
"unsupported_provider",
"integrity_mismatch",
"truncated",
"cancelled",
"timeout",
"unavailable",
"execution_failure"
]
},
"message": { "type": "string", "minLength": 1 },
"retryable": { "type": "boolean" },
"remediation": {
"type": "object",
"properties": {
"action": { "type": "string", "minLength": 1 },
"restart_required": { "type": "boolean" },
"elicitation_supported": { "type": "boolean" }
},
"required": ["action", "restart_required"],
"additionalProperties": false
},
"details": {
"type": "object",
"propertyNames": { "type": "string" },
"additionalProperties": { "$ref": "#/$defs/__schema0" }
}
},
"required": ["code", "category", "message", "retryable", "remediation"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"code": { "type": "string", "const": "provider_timeout" },
"category": {
"type": "string",
"enum": [
"invalid_input",
"permission_required",
"unsupported_provider",
"integrity_mismatch",
"truncated",
"cancelled",
"timeout",
"unavailable",
"execution_failure"
]
},
"message": { "type": "string", "minLength": 1 },
"retryable": { "type": "boolean" },
"remediation": {
"type": "object",
"properties": {
"action": { "type": "string", "minLength": 1 },
"restart_required": { "type": "boolean" },
"elicitation_supported": { "type": "boolean" }
},
"required": ["action", "restart_required"],
"additionalProperties": false
},
"details": {
"type": "object",
"propertyNames": { "type": "string" },
"additionalProperties": { "$ref": "#/$defs/__schema0" }
}
},
"required": ["code", "category", "message", "retryable", "remediation"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"code": { "type": "string", "const": "cancelled" },
"category": {
"type": "string",
"enum": [
"invalid_input",
"permission_required",
"unsupported_provider",
"integrity_mismatch",
"truncated",
"cancelled",
"timeout",
"unavailable",
"execution_failure"
]
},
"message": { "type": "string", "minLength": 1 },
"retryable": { "type": "boolean" },
"remediation": {
"type": "object",
"properties": {
"action": { "type": "string", "minLength": 1 },
"restart_required": { "type": "boolean" },
"elicitation_supported": { "type": "boolean" }
},
"required": ["action", "restart_required"],
"additionalProperties": false
},
"details": {
"type": "object",
"propertyNames": { "type": "string" },
"additionalProperties": { "$ref": "#/$defs/__schema0" }
}
},
"required": ["code", "category", "message", "retryable", "remediation"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"code": { "type": "string", "const": "artifact_integrity_mismatch" },
"category": {
"type": "string",
"enum": [
"invalid_input",
"permission_required",
"unsupported_provider",
"integrity_mismatch",
"truncated",
"cancelled",
"timeout",
"unavailable",
"execution_failure"
]
},
"message": { "type": "string", "minLength": 1 },
"retryable": { "type": "boolean" },
"remediation": {
"type": "object",
"properties": {
"action": { "type": "string", "minLength": 1 },
"restart_required": { "type": "boolean" },
"elicitation_supported": { "type": "boolean" }
},
"required": ["action", "restart_required"],
"additionalProperties": false
},
"details": {
"type": "object",
"properties": {
"logical_path": { "type": "string" },
"declared_sha256": {
"anyOf": [{ "type": "string" }, { "type": "null" }]
},
"calculated_sha256": {
"anyOf": [{ "type": "string" }, { "type": "null" }]
},
"unpacked": { "type": "boolean" }
},
"required": [
"logical_path",
"declared_sha256",
"calculated_sha256",
"unpacked"
],
"additionalProperties": false
}
},
"required": [
"code",
"category",
"message",
"retryable",
"remediation",
"details"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"code": { "type": "string", "const": "artifact_operation_failed" },
"category": {
"type": "string",
"enum": [
"invalid_input",
"permission_required",
"unsupported_provider",
"integrity_mismatch",
"truncated",
"cancelled",
"timeout",
"unavailable",
"execution_failure"
]
},
"message": { "type": "string", "minLength": 1 },
"retryable": { "type": "boolean" },
"remediation": {
"type": "object",
"properties": {
"action": { "type": "string", "minLength": 1 },
"restart_required": { "type": "boolean" },
"elicitation_supported": { "type": "boolean" }
},
"required": ["action", "restart_required"],
"additionalProperties": false
},
"details": {
"type": "object",
"propertyNames": { "type": "string" },
"additionalProperties": { "$ref": "#/$defs/__schema0" }
}
},
"required": ["code", "category", "message", "retryable", "remediation"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"code": { "type": "string", "const": "evidence_integrity_mismatch" },
"category": {
"type": "string",
"enum": [
"invalid_input",
"permission_required",
"unsupported_provider",
"integrity_mismatch",
"truncated",
"cancelled",
"timeout",
"unavailable",
"execution_failure"
]
},
"message": { "type": "string", "minLength": 1 },
"retryable": { "type": "boolean" },
"remediation": {
"type": "object",
"properties": {
"action": { "type": "string", "minLength": 1 },
"restart_required": { "type": "boolean" },
"elicitation_supported": { "type": "boolean" }
},
"required": ["action", "restart_required"],
"additionalProperties": false
},
"details": {
"type": "object",
"propertyNames": { "type": "string" },
"additionalProperties": { "$ref": "#/$defs/__schema0" }
}
},
"required": ["code", "category", "message", "retryable", "remediation"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"code": { "type": "string", "const": "truncated" },
"category": {
"type": "string",
"enum": [
"invalid_input",
"permission_required",
"unsupported_provider",
"integrity_mismatch",
"truncated",
"cancelled",
"timeout",
"unavailable",
"execution_failure"
]
},
"message": { "type": "string", "minLength": 1 },
"retryable": { "type": "boolean" },
"remediation": {
"type": "object",
"properties": {
"action": { "type": "string", "minLength": 1 },
"restart_required": { "type": "boolean" },
"elicitation_supported": { "type": "boolean" }
},
"required": ["action", "restart_required"],
"additionalProperties": false
},
"details": {
"type": "object",
"propertyNames": { "type": "string" },
"additionalProperties": { "$ref": "#/$defs/__schema0" }
}
},
"required": ["code", "category", "message", "retryable", "remediation"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"code": { "type": "string", "const": "permission_required" },
"category": {
"type": "string",
"enum": [
"invalid_input",
"permission_required",
"unsupported_provider",
"integrity_mismatch",
"truncated",
"cancelled",
"timeout",
"unavailable",
"execution_failure"
]
},
"message": { "type": "string", "minLength": 1 },
"retryable": { "type": "boolean" },
"remediation": {
"type": "object",
"properties": {
"action": { "type": "string", "minLength": 1 },
"restart_required": { "type": "boolean" },
"elicitation_supported": { "type": "boolean" }
},
"required": ["action", "restart_required"],
"additionalProperties": false
},
"details": {
"type": "object",
"properties": {
"capability": { "type": "string" },
"requested": {
"type": "object",
"properties": {
"capability": { "type": "string" },
"roots": { "type": "array", "items": { "type": "string" } },
"executables": {
"type": "array",
"items": { "type": "string" }
},
"environment_names": {
"type": "array",
"items": { "type": "string" }
},
"origins": { "type": "array", "items": { "type": "string" } },
"network": {
"type": "string",
"enum": ["none", "loopback", "external"]
},
"mount": { "type": "boolean" }
},
"required": [
"capability",
"roots",
"executables",
"environment_names",
"network",
"mount"
],
"additionalProperties": false
},
"missing": {
"type": "object",
"properties": {
"roots": { "type": "array", "items": { "type": "string" } },
"executables": {
"type": "array",
"items": { "type": "string" }
},
"environment_names": {
"type": "array",
"items": { "type": "string" }
},
"origins": { "type": "array", "items": { "type": "string" } },
"network": {
"type": "string",
"enum": ["none", "loopback", "external"]
},
"mount": { "type": "boolean", "const": true }
},
"additionalProperties": false
},
"ceiling": {
"anyOf": [
{
"type": "object",
"properties": {
"capability": { "type": "string" },
"roots": { "type": "array", "items": { "type": "string" } },
"executables": {
"type": "array",
"items": { "type": "string" }
},
"environment_names": {
"type": "array",
"items": { "type": "string" }
},
"origins": {
"type": "array",
"items": { "type": "string" }
},
"network": {
"type": "string",
"enum": ["none", "loopback", "external"]
},
"mount": { "type": "boolean" }
},
"required": [
"capability",
"roots",
"executables",
"environment_names",
"network",
"mount"
],
"additionalProperties": false
},
{ "type": "null" }
]
}
},
"required": ["capability", "requested", "missing", "ceiling"],
"additionalProperties": false
}
},
"required": ["code", "category", "message", "retryable", "remediation"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"code": { "type": "string", "const": "process_capture_failed" },
"category": {
"type": "string",
"enum": [
"invalid_input",
"permission_required",
"unsupported_provider",
"integrity_mismatch",
"truncated",
"cancelled",
"timeout",
"unavailable",
"execution_failure"
]
},
"message": { "type": "string", "minLength": 1 },
"retryable": { "type": "boolean" },
"remediation": {
"type": "object",
"properties": {
"action": { "type": "string", "minLength": 1 },
"restart_required": { "type": "boolean" },
"elicitation_supported": { "type": "boolean" }
},
"required": ["action", "restart_required"],
"additionalProperties": false
},
"details": {
"type": "object",
"propertyNames": { "type": "string" },
"additionalProperties": { "$ref": "#/$defs/__schema0" }
}
},
"required": ["code", "category", "message", "retryable", "remediation"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"code": { "type": "string", "const": "cleanup_incomplete" },
"category": {
"type": "string",
"enum": [
"invalid_input",
"permission_required",
"unsupported_provider",
"integrity_mismatch",
"truncated",
"cancelled",
"timeout",
"unavailable",
"execution_failure"
]
},
"message": { "type": "string", "minLength": 1 },
"retryable": { "type": "boolean" },
"remediation": {
"type": "object",
"properties": {
"action": { "type": "string", "minLength": 1 },
"restart_required": { "type": "boolean" },
"elicitation_supported": { "type": "boolean" }
},
"required": ["action", "restart_required"],
"additionalProperties": false
},
"details": {
"type": "object",
"propertyNames": { "type": "string" },
"additionalProperties": { "$ref": "#/$defs/__schema0" }
}
},
"required": ["code", "category", "message", "retryable", "remediation"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"code": { "type": "string", "const": "revision_conflict" },
"category": {
"type": "string",
"enum": [
"invalid_input",
"permission_required",
"unsupported_provider",
"integrity_mismatch",
"truncated",
"cancelled",
"timeout",
"unavailable",
"execution_failure"
]
},
"message": { "type": "string", "minLength": 1 },
"retryable": { "type": "boolean" },
"remediation": {
"type": "object",
"properties": {
"action": { "type": "string", "minLength": 1 },
"restart_required": { "type": "boolean" },
"elicitation_supported": { "type": "boolean" }
},
"required": ["action", "restart_required"],
"additionalProperties": false
},
"details": {
"type": "object",
"propertyNames": { "type": "string" },
"additionalProperties": { "$ref": "#/$defs/__schema0" }
}
},
"required": ["code", "category", "message", "retryable", "remediation"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"code": { "type": "string", "const": "outside_approved_root" },
"category": {
"type": "string",
"enum": [
"invalid_input",
"permission_required",
"unsupported_provider",
"integrity_mismatch",
"truncated",
"cancelled",
"timeout",
"unavailable",
"execution_failure"
]
},
"message": { "type": "string", "minLength": 1 },
"retryable": { "type": "boolean" },
"remediation": {
"type": "object",
"properties": {
"action": { "type": "string", "minLength": 1 },
"restart_required": { "type": "boolean" },
"elicitation_supported": { "type": "boolean" }
},
"required": ["action", "restart_required"],
"additionalProperties": false
},
"details": {
"type": "object",
"propertyNames": { "type": "string" },
"additionalProperties": { "$ref": "#/$defs/__schema0" }
}
},
"required": ["code", "category", "message", "retryable", "remediation"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"code": { "type": "string", "const": "configuration_invalid" },
"category": {
"type": "string",
"enum": [
"invalid_input",
"permission_required",
"unsupported_provider",
"integrity_mismatch",
"truncated",
"cancelled",
"timeout",
"unavailable",
"execution_failure"
]
},
"message": { "type": "string", "minLength": 1 },
"retryable": { "type": "boolean" },
"remediation": {
"type": "object",
"properties": {
"action": { "type": "string", "minLength": 1 },
"restart_required": { "type": "boolean" },
"elicitation_supported": { "type": "boolean" }
},
"required": ["action", "restart_required"],
"additionalProperties": false
},
"details": {
"type": "object",
"propertyNames": { "type": "string" },
"additionalProperties": { "$ref": "#/$defs/__schema0" }
}
},
"required": ["code", "category", "message", "retryable", "remediation"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"code": { "type": "string", "const": "target_unavailable" },
"category": {
"type": "string",
"enum": [
"invalid_input",
"permission_required",
"unsupported_provider",
"integrity_mismatch",
"truncated",
"cancelled",
"timeout",
"unavailable",
"execution_failure"
]
},
"message": { "type": "string", "minLength": 1 },
"retryable": { "type": "boolean" },
"remediation": {
"type": "object",
"properties": {
"action": { "type": "string", "minLength": 1 },
"restart_required": { "type": "boolean" },
"elicitation_supported": { "type": "boolean" }
},
"required": ["action", "restart_required"],
"additionalProperties": false
},
"details": {
"type": "object",
"propertyNames": { "type": "string" },
"additionalProperties": { "$ref": "#/$defs/__schema0" }
}
},
"required": ["code", "category", "message", "retryable", "remediation"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"code": { "type": "string", "const": "execution_failure" },
"category": {
"type": "string",
"enum": [
"invalid_input",
"permission_required",
"unsupported_provider",
"integrity_mismatch",
"truncated",
"cancelled",
"timeout",
"unavailable",
"execution_failure"
]
},
"message": { "type": "string", "minLength": 1 },
"retryable": { "type": "boolean" },
"remediation": {
"type": "object",
"properties": {
"action": { "type": "string", "minLength": 1 },
"restart_required": { "type": "boolean" },
"elicitation_supported": { "type": "boolean" }
},
"required": ["action", "restart_required"],
"additionalProperties": false
},
"details": {
"type": "object",
"propertyNames": { "type": "string" },
"additionalProperties": { "$ref": "#/$defs/__schema0" }
}
},
"required": ["code", "category", "message", "retryable", "remediation"],
"additionalProperties": false
}
],
"$defs": {
"__schema0": {
"anyOf": [
{ "type": "string" },
{ "type": "number" },
{ "type": "boolean" },
{ "type": "null" },
{ "type": "array", "items": { "$ref": "#/$defs/__schema0" } },
{
"type": "object",
"propertyNames": { "type": "string" },
"additionalProperties": { "$ref": "#/$defs/__schema0" }
}
]
}
}
}
-11
View File
@@ -31,11 +31,6 @@ Use `--expected-revision N` when an external coordinator needs an explicit
compare-and-swap guard. Traversal, byte, page, and comparison limits are also
available as CLI options.
After a completed run returns its stable run ID, `--replay-run-id RUN_ID`
selects that exact result without reading or rescanning either version input.
The workspace still requires read authority. REA rejects a selector that is
missing, incomplete, or inconsistent with the requested paths and controls.
## MCP
`find_changed_behavior` retains its existing aggregation mode. Its additive
@@ -80,12 +75,6 @@ report checkpoints. A process interrupted after a checkpoint can resume from
the last complete stage. A completed run is reused when both graph commitments
and every bounded option are identical.
By default, REA rescans the version inputs so changed content creates a new
run. For an explicit cache-only replay, add the returned `run_id` as
`investigation_run.replay_run_id`. REA first validates the workspace revision,
the complete run, its inventory paths and controls, and its linked Evidence;
only then may it return without requesting input-read authority.
The run ID is a SHA-256 commitment to both root and graph digests plus the
normalized options. Local paths and timestamps do not affect that identity.
Changing either artifact or a budget creates a new run in the same workspace;
-116
View File
@@ -1,116 +0,0 @@
# MCP runtime contracts
## Identity and discovery
`rea://server/identity` and `binary_session` report the package, server, SDK,
negotiated protocol, skill, and schema-sensitive catalog identities separately.
An absent live comparison is `unknown`, never aligned. Callers can supply the
expected package version, catalog digest, or registered server path to
`binary_session`; a mismatch means the registered MCP process must be restarted.
`rea doctor` separately inspects supported JSON/TOML client registrations,
reports their command vectors as aligned, stale, missing, or invalid, and keeps
live-server state `unknown` unless the active connection supplies identity.
Tool names remain stable. `binary_session.tool_availability` explains each
operation with a stable availability reason instead of silently hiding it.
Opening or closing a target, reloading policy, or observing a provider health
transition emits `notifications/tools/list_changed`.
## Progress and cancellation
REA accepts ordinary `tools/call` progress tokens. Updates are monotonic,
rate-bounded to at most one intermediate update per 100 ms, and always allow a
terminal update. Unknown totals are omitted; REA does not fabricate percentages.
Provider calls receive the request cancellation signal. Artifact traversal,
hashing, cross-version scanning/checkpoints, Hopper requests, and process capture
check the same signal. Cancellation is distinct from timeout. A cleanup failure
uses `cleanup_incomplete` and lists only the owned resource kinds that remain.
Derived comparisons and reconstruction verification yield before computation
and before publication, so cancellation cannot race with successful Evidence.
CLI calls work without a progress token and translate SIGINT into the same
AbortSignal used by providers. Existing controlled-process cleanup and provider
shutdown rules still apply; REA never kills a process it cannot prove it owns.
## Evidence resources
Evidence remains inline for compatibility and successful Evidence-producing
tools also return `resource_link` content. Session resources are connection-local:
- `rea://evidence/{evidenceId}` returns the complete immutable Evidence v2 record.
- `rea://evidence/{evidenceId}/section/{section}` returns a bounded result section.
Stable sections include `result`, `terminal`, `filesystem`, `process`,
`protocol`, `nodes`, `occurrences`, and `edges` when present.
- `rea://unknown/{unknownId}` returns the current residual-unknown head and its
immutable revision history.
- `rea://unknowns/active` returns current unresolved heads.
- `rea://snapshot/{snapshotDigest}` returns the current immutable analysis
snapshot when its canonical content digest matches.
- `rea://artifact/{manifestId}/{collection}` returns a canonical artifact
`nodes`, `occurrences`, or `edges` page with Evidence provenance.
- `rea://function/{targetSha256}/{address}` returns a retained function dossier
for the exact target and address.
- `rea://workspace/{workspaceId}/revision/{revision}` returns an immutable,
CAS-linked investigation workspace revision retained by this session.
Successful automatic cross-version investigations include both Evidence and
workspace `resource_link` blocks. Workspace resources preserve revision and
`previous_revision_digest` commitments; persistent workspace files remain
subject to configured read/write roots.
An Evidence URI is discoverability, not authorization. It cannot authorize file
access, extraction, mounting, execution, or networking. IDs disappear when the
session ends unless the existing Evidence bundle or workspace persistence flow
explicitly retains them.
## Permission policy
All local side effects use one scope vocabulary: capability, canonical roots,
executables, environment variable names, network mode, mount permission, exact
operation identity, and grant lifetime. Environment values and captured content
are never part of a grant or denial.
Existing environment settings map to administrator ceilings. They remain the
maximum authority:
- process roots, executables, environment names, and external networking;
- Evidence read/write roots;
- investigation input and workspace roots;
- snapshot read/write roots;
- reference-source roots;
- native mount enablement.
Artifact extraction retains its existing explicit per-call approval and maps to
an administrator root ceiling of `/` for compatibility.
`rea policy status`, `list`, `explain`, and `revoke` inspect the same evaluator
used by MCP. Optional project grants require both
`REA_PERMISSION_PROJECT_ROOT` and `REA_PERMISSION_PROJECT_STORE`. The store is
atomically written with mode `0600`, bound to the canonical project root, and is
never enabled by default. Send `SIGHUP` to the REA MCP process after a trusted
local policy change; ceilings, administrator grants, and project grants reload
without restarting. Revocation affects future operations; an already-running
operation retains the decision made at its preflight boundary.
Denials use the shared `permission_required` schema with requested scope, missing
scope, administrator ceiling, elicitation support, and exact restart status.
Client-provided roots are context only and never grants.
## Integrity record-and-continue
Artifact integrity remains fail-closed by default. Record-and-continue requires
all three conditions:
1. operator policy `REA_ARTIFACT_INTEGRITY_CONTINUE_ENABLED=true`;
2. `integrity_policy=record-and-continue`;
3. explicit per-call `integrity_continue_approved=true`.
Contradictory bytes are quarantined from nested expansion, recorded with declared
and observed hashes, trust, provenance, path, and unpacked state, and bounded by
`max_integrity_mismatches`. Verified siblings continue. Comparisons classify the
result as a contradiction and reconstruction cannot treat it as unchanged.
`investigate-versions` accepts the same policy through
`--integrity-policy record-and-continue --integrity-continue-approved` and
`--max-integrity-mismatches`. Its Evidence pages and CAS-linked workspace retain
contradictions, so a completed run can resume without rescanning or weakening
trust labels.
+1 -1
View File
@@ -1,7 +1,7 @@
# Guided MCP prompts and completion
REA exposes six provider-neutral investigation workflows through the MCP
`prompts` capability. They complement the 78 tools; they do not add, remove, or
`prompts` capability. They complement the 68 tools; they do not add, remove, or
invoke tools by themselves.
## Prompt inventory
+13 -2
View File
@@ -1,7 +1,18 @@
{
"$schema": "./node_modules/knip/schema.json",
"entry": ["src/main.ts", "src/cli.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.mjs"],
"entry": [
"src/main.ts",
"src/cli.ts",
"scripts/rea.mjs",
"scripts/scan-todos.mjs"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts",
"scripts/**/*.mjs",
"vitest.config.ts"
],
"ignore": ["dist/**", "docs/**", ".codex/**", ".devin/**"],
"ignoreDependencies": [],
"rules": {
"files": "error",
+13 -26
View File
@@ -1,20 +1,18 @@
{
"name": "rea-agents",
"version": "1.5.0",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rea-agents",
"version": "1.5.0",
"hasInstallScript": true,
"version": "1.3.0",
"license": "MIT",
"dependencies": {
"@babel/parser": "8.0.4",
"@babel/types": "8.0.4",
"@electron/asar": "4.2.0",
"@jridgewell/trace-mapping": "0.3.31",
"@modelcontextprotocol/server": "2.0.0-beta.4",
"@modelcontextprotocol/server": "2.0.0-beta.3",
"@xterm/addon-serialize": "0.14.0",
"@xterm/headless": "6.0.0",
"@zip.js/zip.js": "2.8.26",
@@ -38,7 +36,7 @@
},
"devDependencies": {
"@fast-check/vitest": "0.4.1",
"@modelcontextprotocol/client": "2.0.0-beta.4",
"@modelcontextprotocol/client": "2.0.0-beta.3",
"@types/node": "24.10.1",
"@types/write-file-atomic": "4.0.3",
"@types/ws": "8.18.1",
@@ -222,6 +220,7 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@@ -231,12 +230,14 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -244,13 +245,12 @@
}
},
"node_modules/@modelcontextprotocol/client": {
"version": "2.0.0-beta.4",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0-beta.4.tgz",
"integrity": "sha512-VNHA/UXDk7mCpVl+jOg5B4WMRRD2OEl+it360Lhi6HiCbrIB2f6pZ0cqSDKXQVUtZvqnhdQHzZAM9h8EKWhq/A==",
"version": "2.0.0-beta.3",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0-beta.3.tgz",
"integrity": "sha512-o9z9YCGNxWNdklPdjcD0tr8pocC9OgjdArqgx3OjPMlTe0M/SSPC2olOJs5ueq77HSpz36mND0f0KteqEhKF3A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/core": "2.0.0-beta.4",
"cross-spawn": "^7.0.5",
"eventsource": "^3.0.2",
"eventsource-parser": "^3.0.0",
@@ -262,25 +262,12 @@
"node": ">=20"
}
},
"node_modules/@modelcontextprotocol/core": {
"version": "2.0.0-beta.4",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0-beta.4.tgz",
"integrity": "sha512-nsMXd4wQBKzmph6r+WOhum+mXjDYljTAqwY/XUg3hLtvNOQ8+JVqBSJOVCMJvx9lXhTpTOPrGZ3BuNiaNPjSvg==",
"license": "MIT",
"dependencies": {
"zod": "^4.2.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@modelcontextprotocol/server": {
"version": "2.0.0-beta.4",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-beta.4.tgz",
"integrity": "sha512-pjMZcNEt1dOq0aJCcY3b7w1Ayh+qmQN2xlfTELcGV9yiAjEGIczBPBLWWW0k0zzo5T8kiv15jtKPsWeHGxLvLg==",
"version": "2.0.0-beta.3",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-beta.3.tgz",
"integrity": "sha512-EQxVIWf2XMAgjR38ZRZnX1iiOWH42tT/YJG8N4dVjAnrEGSEfPirR6FYfDC72yuNSvVYbPDTD1H3KHLLHPd18A==",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/core": "2.0.0-beta.4",
"zod": "^4.2.0"
},
"engines": {
+4 -8
View File
@@ -1,6 +1,6 @@
{
"name": "rea-agents",
"version": "1.5.0",
"version": "1.3.0",
"description": "Reverse engineer anything from your terminal or agent with one CLI and MCP server.",
"license": "MIT",
"repository": {
@@ -49,7 +49,6 @@
"LICENSE"
],
"scripts": {
"prebuild": "node scripts/generate-package-metadata.mjs",
"build": "tsc -p tsconfig.build.json",
"check": "npm run typecheck && npm run lint && npm run format:check && npm test",
"format": "prettier --write .",
@@ -70,7 +69,6 @@
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"verify:hopper": "npm run build && node scripts/verify-real-hopper.mjs",
"verify:browser": "npm run build && node scripts/verify-real-browser.mjs",
"verify:hopper:linux": "npm run build && node scripts/verify-real-hopper-linux.mjs",
"verify:setup:linux": "npm run build && node scripts/verify-linux-setup-e2e.mjs",
"build:fixtures": "node scripts/build-conformance-fixtures.mjs",
@@ -78,8 +76,7 @@
"verify:package": "npm run build && node scripts/verify-package.mjs",
"verify:published": "node scripts/verify-published-package.mjs",
"config:print": "node scripts/print-mcp-config.mjs",
"docs:generate": "npm run build && typedoc && node scripts/generate-error-schema.mjs",
"docs:error-schema": "npm run build && node scripts/generate-error-schema.mjs",
"docs:generate": "typedoc",
"prepack": "npm run build",
"prepare": "husky",
"rebuild:native": "node scripts/rebuild-native.mjs"
@@ -92,8 +89,7 @@
"@babel/parser": "8.0.4",
"@babel/types": "8.0.4",
"@electron/asar": "4.2.0",
"@jridgewell/trace-mapping": "0.3.31",
"@modelcontextprotocol/server": "2.0.0-beta.4",
"@modelcontextprotocol/server": "2.0.0-beta.3",
"@xterm/addon-serialize": "0.14.0",
"@xterm/headless": "6.0.0",
"@zip.js/zip.js": "2.8.26",
@@ -114,7 +110,7 @@
},
"devDependencies": {
"@fast-check/vitest": "0.4.1",
"@modelcontextprotocol/client": "2.0.0-beta.4",
"@modelcontextprotocol/client": "2.0.0-beta.3",
"@types/node": "24.10.1",
"@types/write-file-atomic": "4.0.3",
"@types/ws": "8.18.1",
-120
View File
@@ -1,120 +0,0 @@
import { createServer } from "node:http";
import { WebSocketServer } from "ws";
/** Start the local HTTP/WS application used by real-browser verification. */
export async function startBrowserVerifierSite() {
let port = 0;
let sessionGeneration = 0;
const server = createServer((request, response) => {
if (request.url === "/app.js.map") {
response.setHeader("content-type", "application/source-map+json");
response.end(sourceMap());
return;
}
if (request.url === "/session-generation") {
response.setHeader("content-type", "application/json");
response.end(JSON.stringify({ generation: sessionGeneration }));
return;
}
if (request.url?.startsWith("/app.js") === true) {
response.setHeader("content-type", "text/javascript");
response.end(browserScript(port));
return;
}
if (request.url?.startsWith("/api") === true) {
response.setHeader("content-type", "application/json");
response.setHeader(
"content-security-policy",
"default-src 'self'; connect-src 'self' ws:",
);
response.setHeader(
"link",
'</.well-known/mcp.json>; rel="service-desc"; type="application/json"',
);
response.setHeader("permissions-policy", "geolocation=(), camera=()");
response.end('{"ok":true,"secret":"response-secret-value"}');
return;
}
response.setHeader("content-type", "text/html");
response.end(
'<!doctype html><html><head><title>REA browser verifier</title></head><body><main><h1>Browser evidence</h1><button aria-label="ax-private-label-value">Verify</button></main><script type="module" src="/app.js?token=script-query-secret"></script></body></html>',
);
});
const webSockets = new WebSocketServer({ noServer: true });
server.on("upgrade", (request, socket, head) => {
webSockets.handleUpgrade(request, socket, head, (webSocket) => {
webSocket.on("message", () =>
webSocket.send("websocket-response-secret"),
);
});
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
if (address === null || typeof address === "string")
throw new Error("Local browser verifier did not bind a TCP port");
port = address.port;
return {
origin: `http://127.0.0.1:${String(port)}`,
triggerSessionNavigation() {
sessionGeneration += 1;
},
async close() {
for (const client of webSockets.clients) client.terminate();
await new Promise((resolve) => webSockets.close(resolve));
await new Promise((resolve, reject) =>
server.close((error) =>
error === undefined ? resolve() : reject(error),
),
);
},
};
}
const browserScript = (port) => `
export const reaSourceMarker = "approved-source-marker";
export const verifiedRoute = { path: "/verified-route" };
localStorage.setItem("rea-storage-key", "storage-secret-value");
indexedDB.open("rea-browser-db", 1);
caches.open("rea-browser-cache");
const observe = () => {
console.log("rea-browser-observation", "authorization=Bearer console-secret-value");
fetch("/api?token=network-secret-value", {
method: "POST",
headers: {
Authorization: "Bearer request-secret-value",
"Content-Type": "application/json"
},
body: JSON.stringify({ token: "request-body-secret-value", active: true })
});
const socket = new WebSocket("ws://127.0.0.1:${String(port)}/live?token=websocket-url-secret");
socket.addEventListener("open", () => socket.send(JSON.stringify({ token: "websocket-secret-value" })));
socket.addEventListener("message", () => socket.close());
};
observe();
setInterval(observe, 150);
let observedGeneration = 0;
setInterval(async () => {
const response = await fetch("/session-generation");
const { generation } = await response.json();
if (generation <= observedGeneration) return;
observedGeneration = generation;
history.pushState({}, "", "/app/session-" + String(generation));
}, 100);
//# sourceMappingURL=/app.js.map
`;
const sourceMap = () =>
JSON.stringify({
version: 3,
file: "app.js",
names: ["reaSourceMarker"],
sources: ["../src/main.ts"],
sourcesContent: [
"import './dependency.ts';\nexport const reaSourceMarker = 'approved-source-marker';",
],
mappings: "AAAAA",
});
-13
View File
@@ -1,13 +0,0 @@
import { writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { analysisErrorJsonSchema } from "../dist/contracts/errorSchemas.js";
import { format } from "prettier";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
await writeFile(
join(root, "docs/error-contract.schema.json"),
await format(JSON.stringify(analysisErrorJsonSchema), { parser: "json" }),
"utf8",
);
-35
View File
@@ -1,35 +0,0 @@
import { readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const packageJson = JSON.parse(
await readFile(join(root, "package.json"), "utf8"),
);
const lock = JSON.parse(
await readFile(join(root, "package-lock.json"), "utf8"),
);
const skill = await readFile(
join(root, "skills/rea-analysis/SKILL.md"),
"utf8",
);
const skillVersion = /^\s{2}version:\s*"([^"]+)"\s*$/mu.exec(skill)?.[1];
if (skillVersion === undefined)
throw new Error("Missing rea-analysis skill metadata version");
const versionOf = (name) => {
const value = lock.packages?.[`node_modules/${name}`]?.version;
if (typeof value !== "string")
throw new Error(`Missing locked ${name} version`);
return value;
};
const source = `/** Generated from package.json and package-lock.json; do not edit. */
export const PACKAGE_METADATA = {
name: ${JSON.stringify(packageJson.name)},
version: ${JSON.stringify(packageJson.version)},
serverSdkVersion: ${JSON.stringify(versionOf("@modelcontextprotocol/server"))},
clientSdkVersion: ${JSON.stringify(versionOf("@modelcontextprotocol/client"))},
coreSdkVersion: ${JSON.stringify(versionOf("@modelcontextprotocol/core"))},
skillVersion: ${JSON.stringify(skillVersion)},
} as const;
`;
await writeFile(join(root, "src/generatedPackageMetadata.ts"), source, "utf8");
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env node
// Route production MCP before importing Incur. Incur owns registration helpers
// such as `mcp add`, while only dist/main.js may serve the 78-tool stdio server.
// such as `mcp add`, while only dist/main.js may serve the 68-tool stdio server.
const args = process.argv.slice(2);
const { default: packageJson } = await import("../package.json", {
with: { type: "json" },
+14 -8
View File
@@ -6,15 +6,21 @@ import { relative, resolve } from "node:path";
const root = resolve(import.meta.dirname, "..");
// Native globSync is available throughout the Node versions in package.engines.
// Its exclusion option is `exclude`; `ignore` and `nodir` belong to npm glob.
const sourceFiles = globSync(
["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.mjs", "bridge/**/*.py"],
{
// Collect all TS, MJS, and Python source files, excluding generated and third-party dirs.
const sourceFiles = [
...globSync("src/**/*.ts", {
cwd: root,
exclude: (path) => path.split(/[\\/]/u).includes("node_modules"),
},
);
nodir: true,
ignore: ["dist/**", "node_modules/**"],
}),
...globSync("tests/**/*.ts", {
cwd: root,
nodir: true,
ignore: ["node_modules/**"],
}),
...globSync("scripts/**/*.mjs", { cwd: root, nodir: true }),
...globSync("bridge/**/*.py", { cwd: root, nodir: true }),
];
const patterns = [
{ regex: /TODO(?::\s*(.*))?$/gm, label: "TODO" },
+114 -194
View File
@@ -114,12 +114,7 @@ try {
const cli = join(prefix, "bin", "rea");
const help = await run(cli, ["--help"], environment);
const llms = await run(cli, ["--llms"], environment);
const doctorExecution = await runWithStatus(
cli,
["doctor", "--json"],
environment,
);
const doctor = json(doctorExecution.stdout);
const doctor = json(await run(cli, ["doctor", "--json"], environment));
const supportedSetupHost =
doctor.checks?.find(({ name }) => name === "host")?.ok === true;
const expectedDoctorHealth = doctor.checks?.every(({ ok }) => ok) === true;
@@ -130,8 +125,6 @@ try {
!help.includes("extract-artifact") ||
!help.includes("investigate-versions") ||
!help.includes("import-reference-source") ||
!help.includes("list-browser-targets") ||
!help.includes("inspect-web-page") ||
!help.includes("compare") ||
!llms.includes("decompile") ||
!llms.includes("function") ||
@@ -142,10 +135,7 @@ try {
!llms.includes("capabilities") ||
!llms.includes("providers") ||
!llms.includes("inventory-artifact") ||
!llms.includes("list-browser-targets") ||
!llms.includes("inspect-web-page") ||
doctor.healthy !== expectedDoctorHealth ||
doctorExecution.status !== (expectedDoctorHealth ? 0 : 1) ||
doctor.checks?.find(({ name }) => name === "hopper")?.ok !== true
)
throw new Error(
@@ -170,88 +160,72 @@ try {
throw new Error("packaged artifact inventory CLI failed");
// prettier-ignore
await verifyPackagedInvestigation({ cli, workspace, evidenceRoot, artifactArchive, environment });
if (process.platform === "linux") {
const unsupportedExecution = await runWithStatus(
const overview = json(
await run(cli, ["analyze", process.execPath, "--json"], environment),
);
if (
overview.operation !== "binary_overview" ||
overview.normalized_result?.procedure_count < 1
)
throw new Error(
`packaged Hopper-backed analyze CLI failed: ${JSON.stringify(overview)}`,
);
const inspected = json(
await run(
cli,
["analyze", process.execPath, "--json"],
[
"inspect",
process.execPath,
"--detail",
"detailed",
"--limit",
"1",
"--json",
],
environment,
),
);
if (
inspected.operation !== "binary_overview" ||
inspected.normalized_result?.detail !== "detailed"
)
throw new Error("packaged inspect CLI failed");
const functionResult = json(
await run(
cli,
["function", process.execPath, "0x1000", "--json"],
environment,
),
);
if (
functionResult.operation !== "analyze_function" ||
functionResult.provider?.id !== "hopper" ||
functionResult.normalized_result?.procedure?.address !== "0x1000"
)
throw new Error(
`packaged function CLI failed: ${JSON.stringify(functionResult)}`,
);
const unsupported = json(unsupportedExecution.stdout);
if (
unsupportedExecution.status !== 1 ||
unsupported.details?.failure_code !== "unsupported_hopper_build"
)
throw new Error(
`packaged Linux Hopper verification did not fail closed: ${JSON.stringify(unsupported)}`,
);
} else {
const overview = json(
await run(cli, ["analyze", process.execPath, "--json"], environment),
);
if (
overview.operation !== "binary_overview" ||
overview.normalized_result?.procedure_count < 1
)
throw new Error(
`packaged Hopper-backed analyze CLI failed: ${JSON.stringify(overview)}`,
);
const inspected = json(
await run(
cli,
[
"inspect",
process.execPath,
"--detail",
"detailed",
"--limit",
"1",
"--json",
],
environment,
),
);
if (
inspected.operation !== "binary_overview" ||
inspected.normalized_result?.detail !== "detailed"
)
throw new Error("packaged inspect CLI failed");
const functionResult = json(
await run(
cli,
["function", process.execPath, "0x1000", "--json"],
environment,
),
);
if (
functionResult.operation !== "analyze_function" ||
functionResult.provider?.id !== "hopper" ||
functionResult.normalized_result?.procedure?.address !== "0x1000"
)
throw new Error(
`packaged function CLI failed: ${JSON.stringify(functionResult)}`,
);
const xrefs = json(
await run(
cli,
["xrefs", process.execPath, "0x1000", "--json"],
environment,
),
);
if (
xrefs.operation !== "xrefs" ||
JSON.stringify(xrefs.normalized_result) !== JSON.stringify(["0x1000"])
)
throw new Error(`packaged xrefs CLI failed: ${JSON.stringify(xrefs)}`);
const trace = json(
await run(
cli,
["trace", process.execPath, "fixture", "--json"],
environment,
),
);
if (trace.operation !== "trace_feature")
throw new Error(`packaged trace CLI failed: ${JSON.stringify(trace)}`);
}
const xrefs = json(
await run(
cli,
["xrefs", process.execPath, "0x1000", "--json"],
environment,
),
);
if (
xrefs.operation !== "xrefs" ||
JSON.stringify(xrefs.normalized_result) !== JSON.stringify(["0x1000"])
)
throw new Error(`packaged xrefs CLI failed: ${JSON.stringify(xrefs)}`);
const trace = json(
await run(
cli,
["trace", process.execPath, "fixture", "--json"],
environment,
),
);
if (trace.operation !== "trace_feature")
throw new Error(`packaged trace CLI failed: ${JSON.stringify(trace)}`);
const capabilities = json(
await run(cli, ["capabilities", "--json"], environment),
);
@@ -263,22 +237,20 @@ try {
providers.providers.some(({ id }) => typeof id !== "string")
)
throw new Error("packaged providers CLI failed");
if (process.platform !== "linux") {
const searchResult = json(
await run(
cli,
["search", process.execPath, "fixture", "--json"],
environment,
),
const searchResult = json(
await run(
cli,
["search", process.execPath, "fixture", "--json"],
environment,
),
);
if (
searchResult.operation !== "search_strings" ||
searchResult.normalized_result?.items?.length !== 1
)
throw new Error(
`packaged search CLI failed: ${JSON.stringify(searchResult)}`,
);
if (
searchResult.operation !== "search_strings" ||
searchResult.normalized_result?.items?.length !== 1
)
throw new Error(
`packaged search CLI failed: ${JSON.stringify(searchResult)}`,
);
}
const referenceImport = json(
await run(
cli,
@@ -356,49 +328,32 @@ try {
await writeFile(skillPath, "stale managed skill\n");
await writeFile(siblingSkillPath, "unrelated skill\n");
}
const plannedExecution = await runWithStatus(
cli,
["setup", "--json"],
environment,
);
const planned = json(plannedExecution.stdout);
const planned = json(await run(cli, ["setup", "--json"], environment));
if (supportedSetupHost) {
const plannedClaudeConfig = await readFile(claudeConfig, "utf8");
const plannedCursorConfig = await readFile(cursorConfig, "utf8");
const plannedSkill = await readFile(skillPath, "utf8");
if (
planned.status !== "needs_confirmation" ||
plannedExecution.status !== 1 ||
planned.appliedActions.length !== 0 ||
!planned.plannedActions.some(({ kind }) => kind === "configure_client") ||
!planned.plannedActions.some(({ kind }) => kind === "install_skill") ||
plannedClaudeConfig !== '{"existing":true}\n' ||
plannedCursorConfig !== '{"existing":true}\n' ||
plannedSkill !== "stale managed skill\n"
(await readFile(claudeConfig, "utf8")) !== '{"existing":true}\n' ||
(await readFile(cursorConfig, "utf8")) !== '{"existing":true}\n' ||
(await readFile(skillPath, "utf8")) !== "stale managed skill\n"
)
throw new Error(
`packaged setup plan was not read-only: ${JSON.stringify({ status: planned.status, exitCode: plannedExecution.status, plannedKinds: planned.plannedActions.map(({ kind }) => kind), appliedKinds: planned.appliedActions.map(({ kind }) => kind), claudeConfig: plannedClaudeConfig, cursorConfig: plannedCursorConfig, skill: plannedSkill })}`,
);
throw new Error("packaged setup plan mutated files before approval");
}
const firstExecution = await runWithStatus(
cli,
["setup", "--yes", "--json"],
environment,
const first = json(await run(cli, ["setup", "--yes", "--json"], environment));
const second = json(
await run(
cli,
["setup", "--yes", "--install-hopper", "--json"],
environment,
),
);
const first = json(firstExecution.stdout);
const secondExecution = await runWithStatus(
cli,
["setup", "--yes", "--install-hopper", "--json"],
environment,
);
const second = json(secondExecution.stdout);
if (supportedSetupHost) {
const status = process.platform === "linux" ? "needs_human" : "ready";
if (
first.status !== status ||
second.status !== status ||
firstExecution.status !== (status === "ready" ? 0 : 1) ||
secondExecution.status !== (status === "ready" ? 0 : 1) ||
second.appliedActions.length !== 0
)
throw new Error("packaged setup was not ready and idempotent");
@@ -432,29 +387,19 @@ try {
throw new Error("packaged stale-skill upgrade was not isolated");
await writeFile(cursorConfig, "malformed");
const failedExecution = await runWithStatus(
cli,
["setup", "--yes", "--json"],
environment,
const failed = json(
await run(cli, ["setup", "--yes", "--json"], environment),
);
const failed = json(failedExecution.stdout);
if (
failed.status !== "needs_human" ||
failedExecution.status !== 1 ||
(await readFile(cursorConfig, "utf8")) !== "malformed"
)
throw new Error("packaged setup failure recovery did not preserve input");
await writeFile(cursorConfig, "{}\n");
const recoveredExecution = await runWithStatus(
cli,
["setup", "--yes", "--json"],
environment,
const recovered = json(
await run(cli, ["setup", "--yes", "--json"], environment),
);
const recovered = json(recoveredExecution.stdout);
if (
recovered.status !== status ||
recoveredExecution.status !== (status === "ready" ? 0 : 1)
)
if (recovered.status !== status)
throw new Error("packaged setup did not recover");
} else if (
first.status !== "needs_human" ||
@@ -496,41 +441,30 @@ try {
);
if (opened.isError === true)
throw new Error("packaged MCP could not open a binary");
await prompts.verifyPromptCompletion(
client,
mcpOptions,
process.platform !== "linux",
);
await prompts.verifyPromptCompletion(client, mcpOptions, true);
const current = await client.callTool(
{ name: "current_document", arguments: {} },
mcpOptions,
);
if (process.platform === "linux") {
if (
current.isError !== true ||
!prompts
.mcpText(current)
.includes("not supported for unattended Linux analysis")
)
throw new Error("packaged Linux MCP did not reject an unpinned Hopper");
} else {
if (json(prompts.mcpText(current)).normalized_result !== "fixture")
throw new Error("packaged MCP bridge call failed");
const batch = await client.callTool(
{ name: "batch_decompile", arguments: { addresses: ["0x1000"] } },
mcpOptions,
);
const batchResult = json(prompts.mcpText(batch)).normalized_result;
if (
batch.isError === true ||
batchResult?.total !== 1 ||
batchResult?.succeeded !== 1 ||
batchResult?.failed !== 0 ||
batchResult?.items?.[0]?.status !== "ok" ||
batchResult?.items?.[0]?.pseudocode !== "return 0;"
)
throw new Error("packaged MCP structured batch result failed");
}
if (
current.isError === true ||
json(prompts.mcpText(current)).normalized_result !== "fixture"
)
throw new Error("packaged MCP bridge call failed");
const batch = await client.callTool(
{ name: "batch_decompile", arguments: { addresses: ["0x1000"] } },
mcpOptions,
);
const batchResult = json(prompts.mcpText(batch)).normalized_result;
if (
batch.isError === true ||
batchResult?.total !== 1 ||
batchResult?.succeeded !== 1 ||
batchResult?.failed !== 0 ||
batchResult?.items?.[0]?.status !== "ok" ||
batchResult?.items?.[0]?.pseudocode !== "return 0;"
)
throw new Error("packaged MCP structured batch result failed");
const closed = await client.callTool(
{ name: "close_binary", arguments: {} },
mcpOptions,
@@ -569,20 +503,6 @@ try {
async function run(command, args, env) {
return (await exec(command, args, { env })).stdout;
}
async function runWithStatus(command, args, env) {
try {
return { stdout: await run(command, args, env), status: 0 };
} catch (cause) {
if (
typeof cause === "object" &&
cause !== null &&
cause.code === 1 &&
typeof cause.stdout === "string"
)
return { stdout: cause.stdout, status: 1 };
throw cause;
}
}
function json(text) {
return JSON.parse(text);
}
+2 -2
View File
@@ -22,7 +22,7 @@ transport.stderr?.on("data", (chunk) => {
try {
await client.connect(transport);
const tools = await client.listTools();
if (tools.tools.length !== 78)
if (tools.tools.length !== 68)
throw new Error(
`Published MCP exposed ${String(tools.tools.length)} tools`,
);
@@ -40,5 +40,5 @@ try {
}
process.stdout.write(
`${JSON.stringify({ package: "rea-agents", version, mcpTools: 78 })}\n`,
`${JSON.stringify({ package: "rea-agents", version, mcpTools: 68 })}\n`,
);
-466
View File
@@ -1,466 +0,0 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { access, mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { CdpBrowserProvider } from "../dist/browser/CdpBrowserProvider.js";
import {
inspectWebPageInputSchema,
listBrowserTargetsInputSchema,
} from "../dist/domain/browserObservation.js";
import { analyzeWebBundleInputSchema } from "../dist/domain/webBundleAnalysis.js";
import { observeWebSessionInputSchema } from "../dist/domain/browserSession.js";
import { compareWebCapturesInputSchema } from "../dist/domain/webCaptureDiff.js";
import {
captureWebScreenshotInputSchema,
compareWebScreenshotsInputSchema,
} from "../dist/domain/webScreenshot.js";
import { startBrowserVerifierSite } from "./fixtures/browser-verifier-site.mjs";
const SECRET_VALUES = [
"network-secret-value",
"console-secret-value",
"storage-secret-value",
"websocket-secret-value",
"ax-private-label-value",
];
const executable = await browserExecutable();
const profile = await mkdtemp(join(tmpdir(), "rea-real-browser-"));
const site = await startBrowserVerifierSite();
let browser;
try {
browser = spawn(
executable,
[
"--headless=new",
"--remote-debugging-address=127.0.0.1",
"--remote-debugging-port=0",
`--user-data-dir=${profile}`,
"--no-first-run",
"--no-default-browser-check",
"--disable-background-networking",
"--disable-component-update",
"--disable-sync",
"--metrics-recording-only",
`${site.origin}/app?startup=browser-secret-value`,
],
{ stdio: ["ignore", "ignore", "pipe"] },
);
let stderr = "";
browser.stderr.on("data", (chunk) => {
if (stderr.length < 64 * 1_024) stderr += chunk.toString("utf8");
});
const port = await devtoolsPort(profile, browser, () => stderr);
const endpoint = `http://127.0.0.1:${String(port)}`;
const provider = new CdpBrowserProvider();
const target = await pageTarget(provider, endpoint, site.origin);
const observed = await provider.inspectPage(
inspectWebPageInputSchema.parse({
cdp_endpoint: endpoint,
allowed_origins: [site.origin],
approved: true,
target_id: target,
observation_ms: 1_000,
include_storage_keys: true,
}),
);
if (!observed.ok) throw observed.error;
assertObservation(observed.value, site.origin);
const serialized = JSON.stringify(observed.value);
for (const secret of [...SECRET_VALUES, "browser-secret-value"])
if (serialized.includes(secret))
throw new Error(
`Passive browser result retained redacted value: ${secret}`,
);
const withSource = await provider.inspectPage(
inspectWebPageInputSchema.parse({
cdp_endpoint: endpoint,
allowed_origins: [site.origin],
approved: true,
target_id: target,
observation_ms: 200,
include_accessibility_text: true,
include_script_sources: true,
include_console_text: true,
console_text_approved: true,
include_json_body_shapes: true,
json_body_schema_approved: true,
include_websocket_shapes: true,
websocket_shape_approved: true,
}),
);
if (!withSource.ok) throw withSource.error;
const source = withSource.value.scripts.items.find(
(script) =>
script.source.included &&
script.source.artifact.text.includes("reaSourceMarker"),
);
if (source === undefined)
throw new Error(
"Real Chrome did not return explicitly approved script source",
);
if (
!withSource.value.accessibility.nodes.some((node) =>
node.name?.includes("ax-private-label-value"),
)
)
throw new Error(
"Real Chrome did not return independently approved accessibility text",
);
assertSensitiveShapes(withSource.value);
const bundle = await provider.analyzeBundle(
analyzeWebBundleInputSchema.parse({
cdp_endpoint: endpoint,
allowed_origins: [site.origin],
approved: true,
target_id: target,
observation_ms: 200,
source_capture_approved: true,
fetch_source_maps: true,
source_map_fetch_approved: true,
}),
);
if (!bundle.ok) throw bundle.error;
assertBundleAnalysis(bundle.value);
const captureDiff = await provider.compareCaptures(
compareWebCapturesInputSchema.parse({
before: { inspection: observed.value },
after: { inspection: withSource.value },
}),
);
if (!captureDiff.ok) throw captureDiff.error;
if (
captureDiff.value.dimensions.dom_structure.status !== "unchanged" ||
captureDiff.value.dimensions.scripts.status !== "unchanged"
)
throw new Error("Real Chrome stable capture identities did not reconcile");
const screenshot = await provider.captureScreenshot(
captureWebScreenshotInputSchema.parse({
cdp_endpoint: endpoint,
allowed_origins: [site.origin],
approved: true,
screenshot_approved: true,
target_id: target,
}),
);
if (!screenshot.ok) throw screenshot.error;
if (
screenshot.value.viewport.width < 1 ||
screenshot.value.viewport.height < 1 ||
screenshot.value.artifact.bytes < 1
)
throw new Error("Real Chrome screenshot artifact was empty");
const screenshotDiff = await provider.compareScreenshots(
compareWebScreenshotsInputSchema.parse({
before: screenshot.value.artifact,
after: screenshot.value.artifact,
}),
);
if (!screenshotDiff.ok) throw screenshotDiff.error;
if (
screenshotDiff.value.status !== "identical" ||
screenshotDiff.value.changed_pixels !== 0
)
throw new Error("Real Chrome PNG artifact did not compare identically");
const sessionPromise = provider.observeSession(
observeWebSessionInputSchema.parse({
cdp_endpoint: endpoint,
allowed_origins: [site.origin],
approved: true,
target_id: target,
observation_ms: 1_500,
}),
);
await delay(250);
site.triggerSessionNavigation();
const session = await sessionPromise;
if (!session.ok) throw session.error;
if (
!session.value.timeline.some(
({ type }) => type === "same_document_navigation",
) ||
!session.value.target.final_url?.includes("/app/session-1")
)
throw new Error("Real Chrome same-origin SPA timeline was missing");
process.stdout.write(
`${JSON.stringify({
browser: observed.value.browser.product,
endpoint,
target,
domNodes: observed.value.dom.nodes.length,
accessibilityNodes: observed.value.accessibility.nodes.length,
scripts: observed.value.scripts.items.length,
networkRequests: observed.value.network.requests.length,
consoleEvents: observed.value.console.events.length,
websocketEvents: observed.value.network.websocket_events.length,
bundleScripts: bundle.value.capture.scripts_analyzed,
sourceMaps: bundle.value.observations.source_maps.processed,
sessionEvents: session.value.timeline.length,
screenshotBytes: screenshot.value.artifact.bytes,
verified: true,
})}\n`,
);
} finally {
if (browser !== undefined) await stopProcess(browser);
await site.close();
await rm(profile, {
recursive: true,
force: true,
maxRetries: 10,
retryDelay: 100,
});
}
async function browserExecutable() {
const candidates = [
process.env.REA_BROWSER_EXECUTABLE,
process.argv[2],
"/usr/bin/google-chrome-stable",
"/usr/bin/google-chrome",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
].filter(
(candidate) => typeof candidate === "string" && candidate.length > 0,
);
for (const candidate of candidates) {
try {
await access(candidate);
return candidate;
} catch {
// Continue through explicit and platform-default candidates.
}
}
throw new Error(
"No Chrome-family executable found; set REA_BROWSER_EXECUTABLE to run real-browser verification",
);
}
async function devtoolsPort(profile, child, stderr) {
const activePort = join(profile, "DevToolsActivePort");
for (let attempt = 0; attempt < 200; attempt += 1) {
if (child.exitCode !== null)
throw new Error(`Chrome exited before CDP startup: ${stderr()}`);
try {
const [port] = (await readFile(activePort, "utf8")).trim().split("\n");
const parsed = Number(port);
if (Number.isSafeInteger(parsed) && parsed > 0) return parsed;
} catch {
// Chrome writes this file only after its CDP listener is ready.
}
await delay(25);
}
throw new Error(`Timed out waiting for Chrome CDP startup: ${stderr()}`);
}
async function pageTarget(provider, endpoint, origin) {
for (let attempt = 0; attempt < 100; attempt += 1) {
const listed = await provider.listTargets(
listBrowserTargetsInputSchema.parse({
cdp_endpoint: endpoint,
allowed_origins: [origin],
approved: true,
}),
);
if (listed.ok && listed.value.targets.items[0] !== undefined)
return listed.value.targets.items[0].target_id;
await delay(25);
}
throw new Error("Real Chrome did not expose the local test page target");
}
function assertObservation(result, origin) {
if (result.target.origin !== origin)
throw new Error("Real Chrome target origin was not preserved exactly");
if (result.frames.length < 1 || result.dom.nodes.length < 1)
throw new Error(
`Real Chrome DOM/frame observation was empty: ${JSON.stringify({ frames: result.frames.length, domTotal: result.dom.total_nodes, domReturned: result.dom.nodes.length, limitations: result.limitations })}`,
);
if (result.accessibility.nodes.length < 1)
throw new Error("Real Chrome accessibility observation was empty");
if (
result.accessibility.text_capture.status !== "not_approved" ||
result.accessibility.nodes.some(
(node) => node.name !== null || node.description !== null,
)
)
throw new Error("Accessibility text was retained without approval");
if (!result.scripts.items.some((script) => script.url.includes("/app.js")))
throw new Error("Real Chrome script metadata was missing");
if (!result.resources.some((resource) => resource.url.includes("/app.js")))
throw new Error("Real Chrome resource metadata was missing");
if (!result.network.requests.some((request) => request.url.includes("/api")))
throw new Error(
"Real Chrome attach-window network observation was missing",
);
const initiatedRequest = result.network.requests.find((request) =>
request.url.includes("/api"),
);
if (
!initiatedRequest?.initiator.url?.includes("/app.js") ||
initiatedRequest.initiator.line === null ||
initiatedRequest.initiator.column === null
)
throw new Error("Real Chrome script initiator stack was not normalized");
if (result.console.events.length < 1)
throw new Error(
"Real Chrome attach-window console observation was missing",
);
if (result.network.websocket_events.length < 1)
throw new Error("Real Chrome WebSocket metadata was missing");
if (
result.console.events.some(
(event) => event.text_capture.status !== "not_approved",
) ||
result.network.requests.some(
(request) => request.body_shapes.status !== "not_approved",
) ||
result.network.websocket_events.some(
(event) => event.payload_shape !== null,
)
)
throw new Error(
"Sensitive text or payload shapes were retained without approval",
);
if (!result.storage.local_storage_keys.includes("rea-storage-key"))
throw new Error("Real Chrome local-storage key inventory was missing");
if (!result.storage.indexed_db_names.includes("rea-browser-db"))
throw new Error("Real Chrome IndexedDB name inventory was missing");
if (!result.storage.cache_names.includes("rea-browser-cache"))
throw new Error("Real Chrome cache name inventory was missing");
if (
!result.metadata.responses.some(({ csp }) =>
csp.directives.some(({ name }) => name === "default-src"),
) ||
!result.metadata.agent_hints.some(
({ declaration }) => declaration === "service-desc",
)
)
throw new Error("Real Chrome safe response metadata was missing");
}
function assertBundleAnalysis(result) {
if (
result.capture.scripts_analyzed < 1 ||
!result.observations.routes.some(({ value }) =>
value.includes("/verified-route"),
) ||
!result.observations.endpoints.some(({ value }) => value.includes("/api"))
)
throw new Error("Real Chrome static bundle findings were missing");
if (
result.observations.source_maps.status !== "included" ||
!result.observations.source_maps.items.some(
({ status, original_sources, original_module_edges, mappings }) =>
status === "included" &&
original_sources.some(({ source }) =>
source.includes("/src/main.ts"),
) &&
original_module_edges.some(
({ specifier }) => specifier === "./dependency.ts",
) &&
mappings.length > 0,
)
)
throw new Error(
`Real Chrome approved source-map reconstruction was missing: ${JSON.stringify(sourceMapSummary(result.observations.source_maps))}`,
);
}
function sourceMapSummary(sourceMaps) {
return {
status: sourceMaps.status,
requested: sourceMaps.requested,
processed: sourceMaps.processed,
dropped: sourceMaps.dropped,
items: sourceMaps.items.map((item) => ({
status: item.status,
declaredUrl: item.declared_url,
sources: item.original_sources.map(({ source }) => source),
edgeSpecifiers: item.original_module_edges.map(({ specifier }) =>
specifier.slice(0, 256),
),
mappings: item.mappings.length,
limitation: item.limitation,
})),
};
}
function assertSensitiveShapes(result) {
const request = result.network.requests.find((item) =>
item.url.includes("/api"),
);
const requestPaths = request?.body_shapes.request?.properties.map(
({ path }) => path,
);
const responsePaths = request?.body_shapes.response?.properties.map(
({ path }) => path,
);
if (!requestPaths?.includes("/token") || !responsePaths?.includes("/secret"))
throw new Error("Real Chrome JSON request/response shapes were missing");
const consoleText = result.console.events.flatMap(
(event) => event.text_capture.values,
);
if (
!consoleText.some(({ text }) => text.includes("[REDACTED]")) ||
consoleText.some(({ text }) => text.includes("console-secret-value"))
)
throw new Error(
"Real Chrome approved console text was not safely redacted",
);
if (
!result.network.websocket_events.some(
(event) =>
event.payload_shape?.format === "json" &&
event.payload_shape.json_shape?.properties.some(
({ path }) => path === "/token",
),
)
)
throw new Error("Real Chrome WebSocket JSON shape was missing");
const serialized = JSON.stringify({
console: result.console,
network: result.network,
});
for (const secret of [
"request-body-secret-value",
"response-secret-value",
"websocket-secret-value",
"console-secret-value",
])
if (serialized.includes(secret))
throw new Error(`Approved shape capture retained raw value: ${secret}`);
}
function delay(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
async function stopProcess(child) {
if (child.exitCode !== null || child.signalCode !== null) return;
const gracefulExit = waitForExit(child);
child.kill("SIGTERM");
const exited = await Promise.race([
gracefulExit.then(() => true),
delay(2_000).then(() => false),
]);
if (!exited) {
if (child.exitCode !== null || child.signalCode !== null) return;
const forcedExit = waitForExit(child);
child.kill("SIGKILL");
await forcedExit;
}
}
function waitForExit(child) {
return new Promise((resolve) => child.once("exit", resolve));
}
+1 -1
View File
@@ -300,7 +300,7 @@ try {
const expectedNames = TOOL_CONTRACTS.map(({ name }) => name).sort();
const actualNames = listed.tools.map(({ name }) => name).sort();
if (JSON.stringify(actualNames) !== JSON.stringify(expectedNames)) {
throw new Error("The real server did not expose the intended 78 tools");
throw new Error("The real server did not expose the intended 68 tools");
}
const options = { timeout };
+3 -28
View File
@@ -1,40 +1,15 @@
---
name: rea-analysis
description: Reverse engineer apps and websites with REA. Explore how features work, then build a version tailored to your project.
description: Reverse engineer apps with REA. Explore how features work, then build a version tailored to your project.
metadata:
version: "14"
tool_count: 78
version: "12"
tool_count: 68
---
# REA
Use REA when the user wants to understand how an app or feature works, compare app versions, decompile code, or build a similar feature.
For a website already open in a user-owned Chrome-family browser, start with
`list_browser_targets` and `inspect_web_page` instead of opening a binary. Use
`analyze_web_bundle`, `observe_web_session`, `discover_webmcp_tools`, capture
comparison, or screenshot tools only for the corresponding investigation. Ask
the user to approve the exact page origins and loopback CDP endpoint before the
first call. Browser observation is disabled unless the operator configured
`REA_BROWSER_OBSERVE_ENABLED`, `REA_BROWSER_CDP_ENDPOINTS_JSON`, and
`REA_BROWSER_ALLOWED_ORIGINS_JSON` before starting REA.
Browser inspection is passive. Never claim that it clicked, navigated,
evaluated page JavaScript, captured prior activity, or contained the page's
network. Query values, credentials, cookies, authorization headers, storage
values, and raw WebSocket or JSON values are deliberately absent. Accessibility
text, bounded redacted console primitives, value-free JSON/WebSocket shapes,
script sources, storage key names, source-map fetches, and screenshot pixels
each require their documented independent opt-in or approval. Treat truncation,
policy filtering, and attach-window coverage as explicit limitations, and cite
the returned Evidence v2 IDs. WebMCP declarations are page-declared untrusted;
REA inventories them but never invokes them.
Electron `file://` pages use `list_electron_targets` and
`inspect_electron_page` with a separate loopback endpoint and canonical
filesystem-root authority. Never claim that Electron inspection invoked IPC,
evaluated renderer JavaScript, navigated, or escaped an approved root.
## Understand the request
Identify the app and what the user wants to understand or build. Do not ask for information they already provided.
+1 -3
View File
@@ -9,11 +9,9 @@ import type { Result } from "../domain/result.js";
import type { EvidenceLocation } from "../domain/evidence.js";
import type { EvidenceSubjectTarget } from "../domain/evidence.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import type { ProgressReporter } from "./ProgressReporter.js";
export interface ExecutionOptions {
interface ExecutionOptions {
readonly signal?: AbortSignal;
readonly progress?: ProgressReporter;
}
/** Closed operations accepted by provider-backed analysis sessions. */
+8 -8
View File
@@ -95,11 +95,11 @@ export class AnalysisSnapshotCache {
this.#target.architecture !== snapshot.target.architecture)
)
this.#entries.clear();
this.#target = structuredClone(snapshot.target);
this.#target = snapshot.target;
let imported = 0;
for (const entry of snapshot.entries) {
if (!this.#entries.has(entry.query_id)) imported += 1;
this.#entries.set(entry.query_id, structuredClone(entry));
this.#entries.set(entry.query_id, entry);
}
return imported;
}
@@ -114,7 +114,7 @@ export class AnalysisSnapshotCache {
snapshot_version: 1,
target: snapshotTarget(target),
entries: this.entries(),
evidence_bundle: structuredClone(evidenceBundle),
evidence_bundle: evidenceBundle,
});
}
@@ -138,9 +138,9 @@ export class AnalysisSnapshotCache {
/** Return canonical entries for persistence. */
entries(): AnalysisSnapshotEntry[] {
return [...this.#entries.values()]
.sort((left, right) => left.query_id.localeCompare(right.query_id))
.map((entry) => structuredClone(entry));
return [...this.#entries.values()].sort((left, right) =>
left.query_id.localeCompare(right.query_id),
);
}
/** Replay an exact provider-specific query, marking its cached provenance. */
@@ -159,7 +159,7 @@ export class AnalysisSnapshotCache {
const cached = this.#entries.get(queryId);
if (cached === undefined) return undefined;
const subject = cached.execution.subject;
return structuredClone({
return {
result: cached.execution.result,
rawResult: cached.execution.raw_result,
provider: cached.execution.provider,
@@ -183,7 +183,7 @@ export class AnalysisSnapshotCache {
format: subject.format,
architecture: subject.architecture,
},
});
};
}
/** Record one successful immutable call unless the cache is full. */
+6 -16
View File
@@ -30,17 +30,6 @@ export interface MutableOccurrence {
limitations: string[];
}
/** Index the first occurrence for each canonical logical path. */
export const indexOccurrencesByPath = (
occurrences: readonly MutableOccurrence[],
): ReadonlyMap<string, MutableOccurrence> => {
const byPath = new Map<string, MutableOccurrence>();
for (const occurrence of occurrences)
if (!byPath.has(occurrence.logical_path))
byPath.set(occurrence.logical_path, occurrence);
return byPath;
};
export const createOccurrence = (
entry: ArtifactEntry,
path: string,
@@ -145,7 +134,6 @@ export const createArtifactNode = (input: {
readonly format: ArtifactNode["format"];
readonly executable: boolean;
readonly contentState: ArtifactNode["content_state"];
readonly limitations?: readonly string[];
}): ArtifactNode => ({
artifact_id: `art_${digestCanonical({ schema_version: 1, sha256: input.sha256 })}`,
kind: input.kind,
@@ -156,7 +144,7 @@ export const createArtifactNode = (input: {
architecture: null,
executable: input.executable,
content_state: input.contentState,
limitations: [...(input.limitations ?? [])],
limitations: [],
});
export const rootOccurrenceFor = (
@@ -207,7 +195,6 @@ export const createArtifactEdges = (
producer?: ArtifactCommand,
): ArtifactEdge[] => {
const byId = new Map(occurrences.map((item) => [item.occurrence_id, item]));
const byPath = indexOccurrencesByPath(occurrences);
const edges: ArtifactEdge[] = [];
for (const occurrence of occurrences) {
if (
@@ -215,8 +202,11 @@ export const createArtifactEdges = (
occurrence.artifact_id === null
)
continue;
const mappedSource = occurrence.logical_path.toLowerCase().endsWith(".map")
? byPath.get(occurrence.logical_path.slice(0, -".map".length))
const mappedSource = occurrence.logical_path.endsWith(".map")
? occurrences.find(
({ logical_path: path }) =>
path === occurrence.logical_path.slice(0, -".map".length),
)
: undefined;
const parentArtifactId =
mappedSource?.artifact_id ??
+23 -156
View File
@@ -23,7 +23,6 @@ import {
artifactInventoryResultSchema,
type ArtifactInventoryResult,
type ArtifactNode,
type IntegrityContradiction,
} from "../domain/artifactGraph.js";
import {
classifyArtifactPath,
@@ -33,7 +32,6 @@ import {
createOccurrence,
createRootNode,
digestCanonical,
indexOccurrencesByPath,
materializeDirectoryNodes,
nearestParent,
pageOf,
@@ -63,21 +61,6 @@ const NATIVE_MOUNT_DISABLED: ArtifactNativeMountPolicy = {
nativeMountEnabled: false,
};
/** Explicit caller approval bounded by operator-owned integrity policy. */
export interface ArtifactIntegrityPolicy {
readonly mode: "fail" | "record-and-continue";
readonly approved: boolean;
readonly enabled: boolean;
readonly maxMismatches: number;
}
const STRICT_INTEGRITY_POLICY: ArtifactIntegrityPolicy = {
mode: "fail",
approved: false,
enabled: false,
maxMismatches: 1,
};
/** Immutable inventory produced by one complete artifact scan. */
export interface ArtifactInventorySnapshot {
readonly manifest: ArtifactInventoryResult["manifest"];
@@ -88,7 +71,6 @@ export interface ArtifactInventorySnapshot {
readonly provenance: ReadonlyArray<
ArtifactInventoryResult["provenance"][number]
>;
readonly integrity_contradictions: readonly IntegrityContradiction[];
readonly limitations: readonly string[];
}
@@ -100,7 +82,6 @@ export const inventoryArtifact = async (
options: {
readonly signal?: AbortSignal;
readonly nativeMount?: ArtifactNativeMountPolicy;
readonly integrity?: ArtifactIntegrityPolicy;
} = {},
): Promise<ArtifactInventoryResult> => {
const snapshot = await scanArtifactInventory(
@@ -108,7 +89,6 @@ export const inventoryArtifact = async (
limits,
options.signal,
options.nativeMount ?? NATIVE_MOUNT_DISABLED,
options.integrity ?? STRICT_INTEGRITY_POLICY,
);
return paginateArtifactInventory(snapshot, page);
};
@@ -119,17 +99,10 @@ export const scanArtifactInventory = async (
limits: ArtifactLimits,
signal?: AbortSignal,
nativeMount: ArtifactNativeMountPolicy = NATIVE_MOUNT_DISABLED,
integrity: ArtifactIntegrityPolicy = STRICT_INTEGRITY_POLICY,
): Promise<ArtifactInventorySnapshot> => {
abortIfNeeded(signal);
const path = await realpath(inputPath);
return scanCanonicalArtifactInventory(
path,
limits,
signal,
nativeMount,
integrity,
);
return scanCanonicalArtifactInventory(path, limits, signal, nativeMount);
};
/** Scan one already-canonical artifact path without resolving it again. */
@@ -138,16 +111,7 @@ export const scanCanonicalArtifactInventory = async (
limits: ArtifactLimits,
signal?: AbortSignal,
nativeMount: ArtifactNativeMountPolicy = NATIVE_MOUNT_DISABLED,
integrity: ArtifactIntegrityPolicy = STRICT_INTEGRITY_POLICY,
): Promise<ArtifactInventorySnapshot> => {
if (
integrity.mode === "record-and-continue" &&
(!integrity.approved || !integrity.enabled)
)
throw new ArtifactReaderFailure(
"unavailable",
"Integrity continuation requires explicit approval and operator policy",
);
const metadata = await lstat(path);
const rootFormat = await classifyRoot(path, metadata.isDirectory());
const rootDigest = metadata.isDirectory()
@@ -156,12 +120,7 @@ export const scanCanonicalArtifactInventory = async (
const reader = await createReader(path, rootFormat, nativeMount, signal);
try {
const { nodes, occurrences, pendingContradictions } = await scanReader(
reader,
limits,
signal,
integrity,
);
const { nodes, occurrences } = await scanReader(reader, limits, signal);
materializeDirectoryNodes(occurrences, nodes);
const rootNode = createRootNode({
@@ -178,45 +137,6 @@ export const scanCanonicalArtifactInventory = async (
if (occurrence.parent_occurrence_id === null)
occurrence.parent_occurrence_id = rootOccurrence.occurrence_id;
occurrences.unshift(rootOccurrence);
const occurrenceById = new Map(
occurrences.map((occurrence) => [occurrence.occurrence_id, occurrence]),
);
const occurrenceByPath = indexOccurrencesByPath(occurrences);
const integrityContradictions = pendingContradictions.map(
(contradiction): IntegrityContradiction => {
const occurrence = occurrenceByPath.get(contradiction.logicalPath);
if (occurrence === undefined)
throw new ArtifactReaderFailure(
"integrity",
"Integrity contradiction lost its graph occurrence",
);
const parent =
occurrence.parent_occurrence_id === null
? undefined
: occurrenceById.get(occurrence.parent_occurrence_id);
const parentArtifactId = parent?.artifact_id ?? rootNode.artifact_id;
return {
contradiction_id: `ic_${digestCanonical({
root_artifact_id: rootNode.artifact_id,
logical_path: contradiction.logicalPath,
declared_sha256: contradiction.declaredSha256,
observed_sha256: contradiction.observedSha256,
})}`,
occurrence_id: occurrence.occurrence_id,
parent_artifact_id: parentArtifactId,
logical_path: contradiction.logicalPath,
declared_sha256: contradiction.declaredSha256,
observed_sha256: contradiction.observedSha256,
entry_kind: contradiction.entryKind,
unpacked: contradiction.unpacked,
trust: "observed-untrusted",
provenance: "container-integrity-metadata-versus-observed-bytes",
limitations: [
"Observed bytes contradict declared integrity metadata and cannot support equivalence.",
],
};
},
);
const edges = createArtifactEdges(
rootNode.artifact_id,
occurrences,
@@ -250,7 +170,6 @@ export const scanCanonicalArtifactInventory = async (
nodes: orderedNodes,
occurrences: orderedOccurrences,
edges: orderedEdges,
integrity_contradictions: integrityContradictions,
});
const manifestId = `agm_${digestCanonical({
schema_version: 1,
@@ -274,15 +193,7 @@ export const scanCanonicalArtifactInventory = async (
edges: orderedEdges,
limits: toOutputLimits(limits),
provenance: reader?.provenance() ?? [],
integrity_contradictions: integrityContradictions,
limitations: [
...inventoryLimitations(rootFormat, reader),
...(integrityContradictions.length === 0
? []
: [
`${String(integrityContradictions.length)} integrity contradiction(s) were recorded; mismatched content is observed-untrusted.`,
]),
],
limitations: inventoryLimitations(rootFormat, reader),
};
} finally {
await reader?.close();
@@ -372,30 +283,18 @@ const isExpandableAsar = (entry: ArtifactEntry, logicalPath: string): boolean =>
const emptyScan = (): {
readonly nodes: Map<string, ArtifactNode>;
readonly occurrences: MutableOccurrence[];
readonly pendingContradictions: PendingIntegrityContradiction[];
} => ({ nodes: new Map(), occurrences: [], pendingContradictions: [] });
interface PendingIntegrityContradiction {
readonly logicalPath: string;
readonly declaredSha256: string;
readonly observedSha256: string;
readonly entryKind: "file" | "slice";
readonly unpacked: boolean;
}
} => ({ nodes: new Map(), occurrences: [] });
const scanReader = async (
reader: ArtifactReader | undefined,
limits: ArtifactLimits,
signal?: AbortSignal,
integrity: ArtifactIntegrityPolicy = STRICT_INTEGRITY_POLICY,
): Promise<{
readonly nodes: Map<string, ArtifactNode>;
readonly occurrences: MutableOccurrence[];
readonly pendingContradictions: PendingIntegrityContradiction[];
}> => {
const { nodes, occurrences, pendingContradictions } = emptyScan();
if (reader === undefined)
return { nodes, occurrences, pendingContradictions };
const { nodes, occurrences } = emptyScan();
if (reader === undefined) return { nodes, occurrences };
const occurrenceByPath = new Map<string, MutableOccurrence>();
const registry = new ArtifactPathRegistry();
let totalBytes = 0;
@@ -403,9 +302,7 @@ const scanReader = async (
currentReader: ArtifactReader,
entry: ArtifactEntry,
logicalPath: string,
): Promise<
{ readonly node: ArtifactNode; readonly mismatched: boolean } | undefined
> => {
): Promise<ArtifactNode | undefined> => {
if ((entry.kind !== "file" && entry.kind !== "slice") || entry.encrypted)
return undefined;
const remainingBytes = limits.maxTotalBytes - totalBytes;
@@ -425,9 +322,7 @@ const scanReader = async (
signal,
);
totalBytes += digest.bytes;
const mismatched =
entry.declaredSha256 !== null && entry.declaredSha256 !== digest.sha256;
if (mismatched && integrity.mode === "fail")
if (entry.declaredSha256 !== null && entry.declaredSha256 !== digest.sha256)
throw new ArtifactReaderFailure(
"integrity",
`Artifact integrity metadata disagrees with content: ${logicalPath}`,
@@ -439,40 +334,18 @@ const scanReader = async (
unpacked: entry.unpacked,
},
);
if (mismatched && entry.declaredSha256 !== null) {
if (pendingContradictions.length >= integrity.maxMismatches)
throw new ArtifactReaderFailure(
"limit",
"Artifact integrity mismatch limit exceeded",
);
pendingContradictions.push({
logicalPath,
declaredSha256: entry.declaredSha256,
observedSha256: digest.sha256,
entryKind: entry.kind,
unpacked: entry.unpacked,
});
}
const classified =
entry.kind === "slice"
? ({ kind: "universal-slice", format: "mach-o" } as const)
: classifyArtifactContent(logicalPath, digest.prefix);
return {
node: createArtifactNode({
sha256: digest.sha256,
size: digest.bytes,
kind: classified.kind,
format: classified.format,
executable: entry.executable,
contentState: "embedded",
limitations: mismatched
? [
"Observed content contradicts declared integrity metadata and is untrusted.",
]
: [],
}),
mismatched,
};
return createArtifactNode({
sha256: digest.sha256,
size: digest.bytes,
kind: classified.kind,
format: classified.format,
executable: entry.executable,
contentState: "embedded",
});
};
const visit = async (
currentReader: ArtifactReader,
@@ -497,26 +370,20 @@ const scanReader = async (
logicalPath,
parent?.occurrence_id ?? null,
);
const digested = await digestEntry(currentReader, entry, logicalPath);
if (digested !== undefined) {
nodes.set(digested.node.artifact_id, digested.node);
occurrence.artifact_id = digested.node.artifact_id;
occurrence.hash_status = digested.mismatched
? "mismatched"
: "verified";
if (digested.mismatched)
occurrence.limitations.push(
"Declared integrity metadata contradicts observed bytes.",
);
const node = await digestEntry(currentReader, entry, logicalPath);
if (node !== undefined) {
nodes.set(node.artifact_id, node);
occurrence.artifact_id = node.artifact_id;
occurrence.hash_status = "verified";
}
occurrences.push(occurrence);
occurrenceByPath.set(logicalPath, occurrence);
if (expandableAsar && digested?.mismatched !== true)
if (expandableAsar)
await visitNestedAsar(entry.adapterKey, logicalPath, visit);
}
};
await visit(reader, "");
return { nodes, occurrences, pendingContradictions };
return { nodes, occurrences };
};
const classifyRoot = async (
+2 -11
View File
@@ -5,10 +5,8 @@ import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { withinRoot } from "../reference/ReferenceSourceReaderPaths.js";
import {
scanCanonicalArtifactInventory,
type ArtifactIntegrityPolicy,
type ArtifactInventorySnapshot,
} from "./ArtifactInventory.js";
import { canonicalizeConfiguredRoots } from "./ConfiguredRoots.js";
/** Resolve, authorize, and scan one artifact without a second path resolution. */
export const scanAuthorizedArtifactInventory = async (
@@ -16,7 +14,6 @@ export const scanAuthorizedArtifactInventory = async (
roots: readonly string[],
limits: ArtifactLimits,
signal?: AbortSignal,
integrity?: ArtifactIntegrityPolicy,
): Promise<ArtifactInventorySnapshot> => {
if (roots.length === 0)
throw new ArtifactReaderFailure(
@@ -25,18 +22,12 @@ export const scanAuthorizedArtifactInventory = async (
);
const [path, approved] = await Promise.all([
realpath(inputPath),
canonicalizeConfiguredRoots(roots),
Promise.all(roots.map(async (root) => realpath(root))),
]);
if (!approved.some((root) => withinRoot(root, path)))
throw new ArtifactReaderFailure(
"path",
"Artifact path is outside approved input roots",
);
return scanCanonicalArtifactInventory(
path,
limits,
signal,
undefined,
integrity,
);
return scanCanonicalArtifactInventory(path, limits, signal);
};
+4 -122
View File
@@ -46,10 +46,6 @@ import {
unknownMutationEvidence,
} from "./UnknownEvidence.js";
import type { BinarySessionPort } from "./BinarySessionPort.js";
import {
parseInvestigationWorkspace,
type InvestigationWorkspace,
} from "../domain/investigationWorkspace.js";
export type { BinarySessionPort } from "./BinarySessionPort.js";
const OFFICIAL_OPERATIONS: ReadonlySet<string> = new Set(
OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name),
@@ -77,12 +73,6 @@ export class BinarySession implements BinarySessionPort {
maxBytes: 64 * 1024 * 1024,
});
readonly #snapshot = new AnalysisSnapshotCache();
readonly #investigationWorkspaces = new Map<string, InvestigationWorkspace>();
readonly #runtimeAvailability = new Map<
string,
{ readonly available: boolean; readonly reason: string | null }
>();
readonly #availabilityListeners = new Set<() => void>();
readonly #snapshotsEnabled: boolean;
#snapshotInvalidated = false;
@@ -113,7 +103,7 @@ export class BinarySession implements BinarySessionPort {
providerIdentity(operation?: AnalysisOperation): ProviderIdentity {
if (operation !== undefined) {
const exact = this.#capabilities?.get(operation)?.provider;
if (exact !== undefined) return structuredClone(exact);
if (exact !== undefined) return exact;
if (enhancedToolNameSchema.safeParse(operation).success) {
const providers = new Map<string, ProviderIdentity>();
for (const descriptor of this.#capabilities?.values() ?? [])
@@ -124,17 +114,11 @@ export class BinarySession implements BinarySessionPort {
providers.set(descriptor.provider.id, descriptor.provider);
if (providers.size === 1) {
const provider = providers.values().next().value;
if (provider !== undefined) return structuredClone(provider);
if (provider !== undefined) return provider;
}
}
}
return structuredClone(this.#providerIdentity);
}
/** Observe runtime provider-health changes that affect discovery metadata. */
onAvailabilityChanged(listener: () => void): () => void {
this.#availabilityListeners.add(listener);
return () => this.#availabilityListeners.delete(listener);
return this.#providerIdentity;
}
/** Add one successful public observation to the session ledger. */
@@ -207,39 +191,6 @@ export class BinarySession implements BinarySessionPort {
);
}
/** Retain one validated immutable workspace revision for session resources. */
retainInvestigationWorkspace(
workspace: InvestigationWorkspace,
): "added" | "duplicate" {
const parsed = parseInvestigationWorkspace(workspace);
const key = `${parsed.workspace_id}:${String(parsed.revision)}`;
if (this.#investigationWorkspaces.has(key)) return "duplicate";
this.#investigationWorkspaces.set(key, parsed);
return "added";
}
/** Read one session-retained immutable workspace revision. */
investigationWorkspace(
workspaceId: string,
revision: number,
): InvestigationWorkspace | undefined {
const workspace = this.#investigationWorkspaces.get(
`${workspaceId}:${String(revision)}`,
);
return workspace === undefined ? undefined : structuredClone(workspace);
}
/** List retained workspace revisions in canonical identity order. */
investigationWorkspaces(): readonly InvestigationWorkspace[] {
return [...this.#investigationWorkspaces.values()]
.sort(
(left, right) =>
left.workspace_id.localeCompare(right.workspace_id) ||
left.revision - right.revision,
)
.map((workspace) => structuredClone(workspace));
}
/** Create an approved residual unknown and immutable mutation evidence. */
recordUnknown(
input: RecordUnknownInput,
@@ -372,7 +323,6 @@ export class BinarySession implements BinarySessionPort {
return err(new AnalysisCancelledError("open_binary"));
}
this.#active = { target: parsed.value, client };
this.#clearRuntimeAvailability();
this.#snapshot.select(parsed.value);
if (options.snapshot !== undefined) {
const imported = this.importAnalysisSnapshot(options.snapshot);
@@ -398,7 +348,6 @@ export class BinarySession implements BinarySessionPort {
this.#evidence.clear();
this.#snapshot.clear();
this.#snapshotInvalidated = false;
this.#clearRuntimeAvailability();
return ok(null);
});
}
@@ -426,10 +375,6 @@ export class BinarySession implements BinarySessionPort {
.sort((left, right) =>
left.operation.localeCompare(right.operation),
)
.map((descriptor) => ({
...descriptor,
...this.#runtimeAvailability.get(descriptor.operation),
}))
.map((descriptor) => ({
operation: descriptor.operation,
available: descriptor.available,
@@ -471,9 +416,7 @@ export class BinarySession implements BinarySessionPort {
/** Return the immutable artifact identity captured before Hopper launched. */
activeTarget(): BinaryTarget | undefined {
return this.#active === undefined
? undefined
: structuredClone(this.#active.target);
return this.#active?.target;
}
/**
@@ -513,7 +456,6 @@ export class BinarySession implements BinarySessionPort {
this.#calls.add(call);
try {
const result = await call;
this.#observeRuntimeAvailability(name, result);
if (result.ok && cacheable)
this.#snapshot.record(active.target, name, arguments_, result.value);
else if (result.ok && capability?.effects.mutatesArtifact === true) {
@@ -526,66 +468,6 @@ export class BinarySession implements BinarySessionPort {
}
}
#observeRuntimeAvailability(
operation: AnalysisOperation,
result: Result<AnalysisExecution, AnalysisError>,
): void {
if (result.ok) {
if (this.#setRuntimeAvailability(operation, true, null))
this.#emitAvailabilityChanged();
return;
}
if (result.error._tag === "AnalysisCapabilityUnavailableError") {
if (this.#setRuntimeAvailability(operation, false, result.error.message))
this.#emitAvailabilityChanged();
return;
}
if (
[
"ProviderAdapterError",
"HopperProcessError",
"HopperStartError",
"HopperRemoteError",
].includes(result.error._tag)
) {
const providerId = this.#capabilities?.get(operation)?.provider.id;
let changed = false;
for (const descriptor of this.#capabilities?.values() ?? [])
if (providerId !== undefined && descriptor.provider.id === providerId)
changed =
this.#setRuntimeAvailability(
descriptor.operation,
false,
"Provider became unavailable during this session.",
) || changed;
if (changed) this.#emitAvailabilityChanged();
}
}
#setRuntimeAvailability(
operation: string,
available: boolean,
reason: string | null,
): boolean {
const current = this.#runtimeAvailability.get(operation);
if (current?.available === available && current.reason === reason)
return false;
if (available && current === undefined) return false;
if (available) this.#runtimeAvailability.delete(operation);
else this.#runtimeAvailability.set(operation, { available, reason });
return true;
}
#clearRuntimeAvailability(): void {
if (this.#runtimeAvailability.size === 0) return;
this.#runtimeAvailability.clear();
this.#emitAvailabilityChanged();
}
#emitAvailabilityChanged(): void {
for (const listener of this.#availabilityListeners) listener();
}
#serialize<T>(operation: () => Promise<T>): Promise<T> {
const result = this.#transition.then(operation, operation);
this.#transition = result.then(
-10
View File
@@ -16,7 +16,6 @@ import type {
UnknownStatus,
UpdateUnknownInput,
} from "../domain/residualUnknown.js";
import type { InvestigationWorkspace } from "../domain/investigationWorkspace.js";
import type {
AnalysisOperation,
AnalysisOperationPort,
@@ -49,14 +48,6 @@ export interface BinarySessionPort extends AnalysisOperationPort {
importAnalysisSnapshot(
snapshot: AnalysisSnapshot,
): Result<number, AnalysisError>;
retainInvestigationWorkspace(
workspace: InvestigationWorkspace,
): "added" | "duplicate";
investigationWorkspace(
workspaceId: string,
revision: number,
): InvestigationWorkspace | undefined;
investigationWorkspaces(): readonly InvestigationWorkspace[];
recordUnknown(
input: RecordUnknownInput,
): Result<ResidualUnknown, AnalysisError>;
@@ -81,5 +72,4 @@ export interface BinarySessionPort extends AnalysisOperationPort {
UnknownRegistryError
>;
providerIdentity(operation?: AnalysisOperation): ProviderIdentity;
onAvailabilityChanged?(listener: () => void): () => void;
}
+33 -8
View File
@@ -5,9 +5,7 @@ import writeFileAtomic from "write-file-atomic";
import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js";
import { EvidenceFileError } from "../domain/errors.js";
import { isJsonWithinLimits } from "../domain/jsonLimits.js";
import { err, ok, type Result } from "../domain/result.js";
import { canonicalizeConfiguredRoots } from "./ConfiguredRoots.js";
/** Read bounded JSON data from a regular file beneath an approved root. */
export const readBoundedJson = async (
@@ -33,9 +31,8 @@ export const readBoundedJson = async (
} catch (cause: unknown) {
return err(new EvidenceFileError("read", "invalid-json", { cause }));
}
return isJsonWithinLimits(decoded, policy)
? ok(decoded)
: err(new EvidenceFileError("read", "too-large"));
const limits = validateJsonLimits(decoded, policy);
return limits.ok ? ok(decoded) : limits;
} catch (cause: unknown) {
return err(new EvidenceFileError("read", "io", { cause }));
}
@@ -85,9 +82,8 @@ const isApproved = async (
candidate: string,
roots: readonly string[],
): Promise<boolean> => {
for (const root of await canonicalizeConfiguredRoots(
roots.map((configuredRoot) => resolve(configuredRoot)),
)) {
for (const configuredRoot of roots) {
const root = await realpath(resolve(configuredRoot));
const relation = relative(root, candidate);
if (
relation === "" ||
@@ -98,6 +94,35 @@ const isApproved = async (
return false;
};
const validateJsonLimits = (
root: unknown,
policy: EvidenceFilePolicy,
): Result<null, EvidenceFileError> => {
const pending: Array<{ readonly value: unknown; readonly depth: number }> = [
{ value: root, depth: 0 },
];
let nodes = 0;
while (pending.length > 0) {
const current = pending.pop();
if (current === undefined) break;
nodes += 1;
if (nodes > policy.maxNodes || current.depth > policy.maxDepth)
return err(new EvidenceFileError("read", "too-large"));
if (
typeof current.value === "string" &&
current.value.length > policy.maxStringLength
)
return err(new EvidenceFileError("read", "too-large"));
if (typeof current.value !== "object" || current.value === null) continue;
for (const [key, value] of Object.entries(current.value)) {
if (key.length > policy.maxStringLength)
return err(new EvidenceFileError("read", "too-large"));
pending.push({ value, depth: current.depth + 1 });
}
}
return ok(null);
};
const isFileNotFound = (cause: unknown): boolean =>
typeof cause === "object" &&
cause !== null &&
-181
View File
@@ -1,181 +0,0 @@
import type { ProviderIdentity } from "./AnalysisProvider.js";
import {
createEvidence,
type Evidence,
type EvidenceObservation,
} from "../domain/evidence.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import type {
BrowserTargetList,
InspectWebPageInput,
ListBrowserTargetsInput,
WebPageInspection,
} from "../domain/browserObservation.js";
import type {
AnalyzeWebBundleInput,
WebBundleAnalysis,
} from "../domain/webBundleAnalysis.js";
import type {
ObserveWebSessionInput,
WebObservationSession,
} from "../domain/browserSession.js";
import type {
DiscoverWebMcpToolsInput,
WebMcpDiscovery,
} from "../domain/webMcpDiscovery.js";
import type {
CompareWebCapturesInput,
WebCaptureDiff,
} from "../domain/webCaptureDiff.js";
import type {
CaptureWebScreenshotInput,
CompareWebScreenshotsInput,
WebScreenshot,
WebScreenshotDiff,
} from "../domain/webScreenshot.js";
type BrowserEvidenceInput =
| ListBrowserTargetsInput
| InspectWebPageInput
| AnalyzeWebBundleInput
| ObserveWebSessionInput
| DiscoverWebMcpToolsInput
| CompareWebCapturesInput
| CaptureWebScreenshotInput
| CompareWebScreenshotsInput;
type BrowserEvidenceResult =
| BrowserTargetList
| WebPageInspection
| WebBundleAnalysis
| WebObservationSession
| WebMcpDiscovery
| WebCaptureDiff
| WebScreenshot
| WebScreenshotDiff;
type BrowserEvidenceOperation =
| "list_browser_targets"
| "inspect_web_page"
| "analyze_web_bundle"
| "observe_web_session"
| "discover_webmcp_tools"
| "compare_web_captures"
| "capture_web_screenshot"
| "compare_web_screenshots";
/** Create Evidence v2 for a policy-scoped external browser observation. */
export const createBrowserEvidence = (
operation: BrowserEvidenceOperation,
input: BrowserEvidenceInput,
result: BrowserEvidenceResult,
provider: ProviderIdentity,
): Evidence =>
createEvidence(undefined, provider, {
predicateType: browserPredicate(operation),
operation,
parameters: browserParameters(input),
result: jsonValueSchema.parse(result),
confidence: "observed",
authority: "external-service",
environment:
"browser" in result
? {
id: `${result.browser.product}@${result.browser.revision}`,
platform: process.platform,
architecture: process.arch,
isolation: "none",
}
: null,
limitations: result.limitations,
});
const browserParameters = (
input: BrowserEvidenceInput,
): EvidenceObservation["parameters"] => {
if (!("cdp_endpoint" in input)) {
if ("max_changes" in input)
return {
before_target_id: input.before.inspection.target.target_id,
before_capture_ended_at:
input.before.inspection.capture_window.ended_at,
after_target_id: input.after.inspection.target.target_id,
after_capture_ended_at: input.after.inspection.capture_window.ended_at,
max_changes: input.max_changes,
};
return {
before_artifact_uri: input.before.uri,
after_artifact_uri: input.after.uri,
channel_threshold: input.channel_threshold,
maximum_pixels: input.maximum_pixels,
};
}
const scope = {
cdp_endpoint: input.cdp_endpoint,
allowed_origins: input.allowed_origins,
};
if (!("target_id" in input))
return { ...scope, offset: input.offset, limit: input.limit };
if ("screenshot_approved" in input)
return {
...scope,
target_id: input.target_id,
maximum_image_bytes: input.maximum_image_bytes,
};
if ("max_tools" in input)
return {
...scope,
target_id: input.target_id,
observation_ms: input.observation_ms,
max_tools: input.max_tools,
max_schema_bytes: input.max_schema_bytes,
max_schema_nodes: input.max_schema_nodes,
max_schema_depth: input.max_schema_depth,
};
if (!("include_accessibility_text" in input))
return {
...scope,
target_id: input.target_id,
observation_ms: input.observation_ms,
max_timeline_events: input.max_timeline_events,
};
return {
...scope,
target_id: input.target_id,
observation_ms: input.observation_ms,
include_accessibility_text: input.include_accessibility_text,
include_console_text: input.include_console_text,
include_json_body_shapes: input.include_json_body_shapes,
include_websocket_shapes: input.include_websocket_shapes,
include_script_sources: input.include_script_sources,
include_storage_keys: input.include_storage_keys,
limits: input.limits,
...(input.include_script_sources && "source_capture_approved" in input
? {
source_capture_approved: input.source_capture_approved,
fetch_source_maps: input.fetch_source_maps,
source_map_fetch_approved: input.source_map_fetch_approved,
analysis_limits: input.analysis_limits,
}
: {}),
};
};
const browserPredicate = (operation: BrowserEvidenceOperation): string => {
switch (operation) {
case "list_browser_targets":
return "rea.browser-target-list/v1";
case "inspect_web_page":
return "rea.web-page-inspection/v2";
case "analyze_web_bundle":
return "rea.web-bundle-analysis/v1";
case "observe_web_session":
return "rea.web-observation-session/v1";
case "discover_webmcp_tools":
return "rea.webmcp-discovery/v1";
case "compare_web_captures":
return "rea.web-capture-diff/v1";
case "capture_web_screenshot":
return "rea.web-screenshot/v1";
case "compare_web_screenshots":
return "rea.web-screenshot-diff/v1";
}
};
-67
View File
@@ -1,67 +0,0 @@
import type { AnalysisError } from "../domain/errors.js";
import type {
BrowserTargetList,
InspectWebPageInput,
ListBrowserTargetsInput,
WebPageInspection,
} from "../domain/browserObservation.js";
import type { Result } from "../domain/result.js";
import type {
AnalyzeWebBundleInput,
WebBundleAnalysis,
} from "../domain/webBundleAnalysis.js";
import type {
ObserveWebSessionInput,
WebObservationSession,
} from "../domain/browserSession.js";
import type {
DiscoverWebMcpToolsInput,
WebMcpDiscovery,
} from "../domain/webMcpDiscovery.js";
import type {
CompareWebCapturesInput,
WebCaptureDiff,
} from "../domain/webCaptureDiff.js";
import type {
CaptureWebScreenshotInput,
CompareWebScreenshotsInput,
WebScreenshot,
WebScreenshotDiff,
} from "../domain/webScreenshot.js";
import type { ProviderIdentity } from "./AnalysisProvider.js";
import type { ExecutionOptions } from "./AnalysisProvider.js";
/** Provider-neutral application boundary for passive browser observation. */
export interface BrowserObservationPort {
identity(): ProviderIdentity;
listTargets(
input: ListBrowserTargetsInput,
options?: ExecutionOptions,
): Promise<Result<BrowserTargetList, AnalysisError>>;
inspectPage(
input: InspectWebPageInput,
options?: ExecutionOptions,
): Promise<Result<WebPageInspection, AnalysisError>>;
analyzeBundle(
input: AnalyzeWebBundleInput,
options?: ExecutionOptions,
): Promise<Result<WebBundleAnalysis, AnalysisError>>;
observeSession(
input: ObserveWebSessionInput,
options?: ExecutionOptions,
): Promise<Result<WebObservationSession, AnalysisError>>;
discoverWebMcpTools(
input: DiscoverWebMcpToolsInput,
options?: ExecutionOptions,
): Promise<Result<WebMcpDiscovery, AnalysisError>>;
compareCaptures(
input: CompareWebCapturesInput,
): Promise<Result<WebCaptureDiff, AnalysisError>>;
captureScreenshot(
input: CaptureWebScreenshotInput,
options?: ExecutionOptions,
): Promise<Result<WebScreenshot, AnalysisError>>;
compareScreenshots(
input: CompareWebScreenshotsInput,
): Promise<Result<WebScreenshotDiff, AnalysisError>>;
}
@@ -1,292 +0,0 @@
import type { Evidence } from "../domain/evidence.js";
import {
isLiteralLoopbackHostname,
type InspectWebPageInput,
type ListBrowserTargetsInput,
} from "../domain/browserObservation.js";
import type { AnalyzeWebBundleInput } from "../domain/webBundleAnalysis.js";
import type { ObserveWebSessionInput } from "../domain/browserSession.js";
import type { DiscoverWebMcpToolsInput } from "../domain/webMcpDiscovery.js";
import type { CompareWebCapturesInput } from "../domain/webCaptureDiff.js";
import type {
CaptureWebScreenshotInput,
CompareWebScreenshotsInput,
} from "../domain/webScreenshot.js";
import {
AnalysisCapabilityUnavailableError,
AnalysisProtocolError,
PermissionRequiredError,
type AnalysisError,
} from "../domain/errors.js";
import { err, ok, type Result } from "../domain/result.js";
import type { ExecutionOptions } from "./AnalysisProvider.js";
import type { BrowserObservationPort } from "./BrowserObservationPort.js";
import type { PermissionAuthority } from "./PermissionAuthority.js";
import { createBrowserEvidence } from "./BrowserEvidence.js";
type ScopedBrowserInput = Pick<
ListBrowserTargetsInput,
"cdp_endpoint" | "allowed_origins"
> & { readonly target_id?: string };
/** Authorize and execute one policy-scoped target discovery observation. */
export const listBrowserTargets = async (
browser: BrowserObservationPort | undefined,
authority: PermissionAuthority | undefined,
input: ListBrowserTargetsInput,
options: ExecutionOptions = {},
): Promise<Result<Evidence, AnalysisError>> => {
const ready = await prepare(
browser,
authority,
input,
"list_browser_targets",
);
if (!ready.ok) return ready;
const result = await ready.value.listTargets(input, options);
return result.ok
? ok(
createBrowserEvidence(
"list_browser_targets",
input,
result.value,
ready.value.identity(),
),
)
: result;
};
/** Authorize and execute one policy-scoped passive page inspection. */
export const inspectWebPage = async (
browser: BrowserObservationPort | undefined,
authority: PermissionAuthority | undefined,
input: InspectWebPageInput,
options: ExecutionOptions = {},
): Promise<Result<Evidence, AnalysisError>> => {
const ready = await prepare(browser, authority, input, "inspect_web_page");
if (!ready.ok) return ready;
const result = await ready.value.inspectPage(input, options);
return result.ok
? ok(
createBrowserEvidence(
"inspect_web_page",
input,
result.value,
ready.value.identity(),
),
)
: result;
};
/** Capture approved sources and derive bounded static web-bundle evidence. */
export const analyzeWebBundle = async (
browser: BrowserObservationPort | undefined,
authority: PermissionAuthority | undefined,
input: AnalyzeWebBundleInput,
options: ExecutionOptions = {},
): Promise<Result<Evidence, AnalysisError>> => {
const ready = await prepare(browser, authority, input, "analyze_web_bundle");
if (!ready.ok) return ready;
const analyzed = await ready.value.analyzeBundle(input, options);
if (!analyzed.ok) return analyzed;
return ok(
createBrowserEvidence(
"analyze_web_bundle",
input,
analyzed.value,
ready.value.identity(),
),
);
};
/** Observe navigation caused by external user actions during one armed window. */
export const observeWebSession = async (
browser: BrowserObservationPort | undefined,
authority: PermissionAuthority | undefined,
input: ObserveWebSessionInput,
options: ExecutionOptions = {},
): Promise<Result<Evidence, AnalysisError>> => {
const ready = await prepare(browser, authority, input, "observe_web_session");
if (!ready.ok) return ready;
const observed = await ready.value.observeSession(input, options);
return observed.ok
? ok(
createBrowserEvidence(
"observe_web_session",
input,
observed.value,
ready.value.identity(),
),
)
: observed;
};
/** Discover WebMCP declarations without exposing an invocation surface. */
export const discoverWebMcpTools = async (
browser: BrowserObservationPort | undefined,
authority: PermissionAuthority | undefined,
input: DiscoverWebMcpToolsInput,
options: ExecutionOptions = {},
): Promise<Result<Evidence, AnalysisError>> => {
const ready = await prepare(
browser,
authority,
input,
"discover_webmcp_tools",
);
if (!ready.ok) return ready;
const discovered = await ready.value.discoverWebMcpTools(input, options);
return discovered.ok
? ok(
createBrowserEvidence(
"discover_webmcp_tools",
input,
discovered.value,
ready.value.identity(),
),
)
: discovered;
};
/** Compare two already-normalized web captures without external access. */
export const compareWebCaptureEvidence = async (
browser: BrowserObservationPort | undefined,
input: CompareWebCapturesInput,
): Promise<Result<Evidence, AnalysisError>> => {
const ready = requireBrowser(browser, "compare_web_captures");
if (!ready.ok) return ready;
const compared = await ready.value.compareCaptures(input);
return compared.ok
? ok(
createBrowserEvidence(
"compare_web_captures",
input,
compared.value,
ready.value.identity(),
),
)
: compared;
};
/** Capture one explicitly approved visible-viewport screenshot. */
export const captureWebScreenshot = async (
browser: BrowserObservationPort | undefined,
authority: PermissionAuthority | undefined,
input: CaptureWebScreenshotInput,
options: ExecutionOptions = {},
): Promise<Result<Evidence, AnalysisError>> => {
const ready = await prepare(
browser,
authority,
input,
"capture_web_screenshot",
);
if (!ready.ok) return ready;
const captured = await ready.value.captureScreenshot(input, options);
return captured.ok
? ok(
createBrowserEvidence(
"capture_web_screenshot",
input,
captured.value,
ready.value.identity(),
),
)
: captured;
};
/** Compare two self-verifying PNG artifacts without external access. */
export const compareWebScreenshotEvidence = async (
browser: BrowserObservationPort | undefined,
input: CompareWebScreenshotsInput,
): Promise<Result<Evidence, AnalysisError>> => {
const ready = requireBrowser(browser, "compare_web_screenshots");
if (!ready.ok) return ready;
const compared = await ready.value.compareScreenshots(input);
return compared.ok
? ok(
createBrowserEvidence(
"compare_web_screenshots",
input,
compared.value,
ready.value.identity(),
),
)
: compared;
};
const prepare = async (
browser: BrowserObservationPort | undefined,
authority: PermissionAuthority | undefined,
input: ScopedBrowserInput,
operation:
| "list_browser_targets"
| "inspect_web_page"
| "analyze_web_bundle"
| "observe_web_session"
| "discover_webmcp_tools"
| "capture_web_screenshot",
): Promise<Result<BrowserObservationPort, AnalysisError>> => {
if (authority === undefined)
return err(
new AnalysisCapabilityUnavailableError(
"rea-cdp-browser",
operation,
"browser observation permission policy is not configured",
),
);
const authorized = await authority.authorize(
{
capability: "browser_observe",
roots: [],
executables: [],
environment_names: [],
origins: [input.cdp_endpoint, ...input.allowed_origins],
network: browserNetworkScope(input.allowed_origins),
mount: false,
operation_identity: `${operation}:${input.target_id ?? input.cdp_endpoint}`,
},
"read",
);
if (!authorized.ok)
return err(
authorized.error instanceof PermissionRequiredError
? authorized.error
: new AnalysisProtocolError(authorized.error.message, {
cause: authorized.error,
}),
);
return browser === undefined
? err(
new AnalysisCapabilityUnavailableError(
"rea-cdp-browser",
operation,
"browser observation provider is not configured",
),
)
: ok(browser);
};
const requireBrowser = (
browser: BrowserObservationPort | undefined,
operation: string,
): Result<BrowserObservationPort, AnalysisError> =>
browser === undefined
? err(
new AnalysisCapabilityUnavailableError(
"rea-cdp-browser",
operation,
"browser comparison provider is not configured",
),
)
: ok(browser);
const browserNetworkScope = (
allowedOrigins: readonly string[],
): "loopback" | "external" =>
allowedOrigins.every((origin) => {
const hostname = new URL(origin).hostname;
return isLiteralLoopbackHostname(hostname);
})
? "loopback"
: "external";
-213
View File
@@ -1,213 +0,0 @@
import { z } from "zod";
import { TOOL_CONTRACTS } from "../contracts/toolContracts.js";
import type { JsonValue } from "../domain/jsonValue.js";
const statusSchema = z.object({
open: z.boolean(),
kind: z.enum(["executable", "database", "archive", "artifact"]).optional(),
format: z.string().optional(),
capabilities: z.array(
z.object({
operation: z.string(),
available: z.boolean(),
reason: z.string().nullable(),
}),
),
});
export type ToolAvailabilityReason =
| "available"
| "target_required"
| "provider_missing"
| "provider_unavailable"
| "target_unsupported"
| "unsupported_host"
| "policy_disabled";
const ENHANCED_REQUIREMENTS: Readonly<Record<string, readonly string[]>> = {
swift_classes: ["list_procedures"],
get_objc_classes: ["list_names"],
get_objc_protocols: ["list_names"],
batch_decompile: ["procedure_pseudo_code"],
get_call_graph: ["procedure_callees", "procedure_callers"],
analyze_swift_types: ["list_procedures"],
find_xrefs_to_name: ["list_names", "xrefs"],
binary_overview: [
"list_segments",
"list_documents",
"list_procedures",
"list_strings",
],
analyze_function: ["analyze_function"],
trace_feature: [
"list_strings",
"list_procedures",
"xrefs",
"resolve_containing_procedure",
],
};
/** Build stable per-operation availability without hiding familiar tools. */
export const buildCapabilityInventory = (
sessionStatus: JsonValue,
policy: {
readonly processCaptureEnabled: boolean;
readonly evidenceFileRoots: number;
readonly browserObservationEnabled?: boolean;
readonly electronObservationEnabled?: boolean;
},
) => {
const status = statusSchema.parse(sessionStatus);
const descriptors = new Map(
status.capabilities.map((descriptor) => [descriptor.operation, descriptor]),
);
return TOOL_CONTRACTS.map((contract) => {
const availability = availabilityFor(
contract.name,
contract.kind,
status.open,
status.kind,
descriptors,
policy,
);
return {
name: contract.name,
surface: contract.kind,
available: availability.reason === "available",
reason: availability.reason,
remediation: availability.remediation,
annotations: {
read_only: contract.annotations.readOnlyHint ?? false,
destructive: contract.annotations.destructiveHint ?? false,
idempotent: contract.annotations.idempotentHint ?? false,
open_world: contract.annotations.openWorldHint ?? true,
},
};
}).sort((left, right) => left.name.localeCompare(right.name));
};
const availabilityFor = (
name: string,
kind: (typeof TOOL_CONTRACTS)[number]["kind"],
targetOpen: boolean,
targetKind: "executable" | "database" | "archive" | "artifact" | undefined,
descriptors: ReadonlyMap<
string,
{ readonly available: boolean; readonly reason: string | null }
>,
policy: {
readonly processCaptureEnabled: boolean;
readonly evidenceFileRoots: number;
readonly browserObservationEnabled?: boolean;
readonly electronObservationEnabled?: boolean;
},
): {
readonly reason: ToolAvailabilityReason;
readonly remediation: string | null;
} => {
if (name === "capture_process_scenario" && !policy.processCaptureEnabled)
return {
reason: "policy_disabled",
remediation:
"Enable or grant process_capture within the administrator ceiling.",
};
if (name === "import_evidence_bundle" && policy.evidenceFileRoots === 0)
return {
reason: "policy_disabled",
remediation: "Configure or grant an evidence_read root.",
};
if (kind === "browser-provider")
return policy.browserObservationEnabled === true
? { reason: "available", remediation: null }
: {
reason: "policy_disabled",
remediation:
"Enable browser observation and configure exact CDP endpoint and page origins.",
};
if (kind === "electron-provider")
return policy.electronObservationEnabled === true
? { reason: "available", remediation: null }
: {
reason: "policy_disabled",
remediation:
"Enable Electron observation and configure a loopback CDP endpoint and canonical file roots.",
};
if (kind === "session") return { reason: "available", remediation: null };
if (!targetOpen)
return {
reason: "target_required",
remediation: "Call open_binary with a supported local target.",
};
if (
targetKind !== undefined &&
targetKind !== "executable" &&
targetKind !== "database" &&
(kind === "official-proxy" || kind === "enhanced")
)
return {
reason: "target_unsupported",
remediation:
"Inventory or extract a native executable, then call open_binary on that executable.",
};
if (kind === "enhanced") return composedAvailability(name, descriptors);
const descriptor = descriptors.get(name);
if (descriptor === undefined)
return {
reason: "provider_missing",
remediation:
"Install or configure a provider that declares this operation.",
};
if (
!descriptor.available &&
descriptor.reason?.toLowerCase().includes("require macos") === true
)
return {
reason: "unsupported_host",
remediation: descriptor.reason,
};
return descriptor.available
? { reason: "available", remediation: null }
: {
reason: "provider_unavailable",
remediation:
descriptor.reason ?? "Choose another target or configured provider.",
};
};
const composedAvailability = (
name: string,
descriptors: ReadonlyMap<
string,
{ readonly available: boolean; readonly reason: string | null }
>,
): {
readonly reason: ToolAvailabilityReason;
readonly remediation: string | null;
} => {
const requirements = ENHANCED_REQUIREMENTS[name];
if (requirements === undefined)
return {
reason: "provider_missing",
remediation: "No provider composition is declared for this operation.",
};
const missing = requirements.find((operation) => !descriptors.has(operation));
if (missing !== undefined)
return {
reason: "provider_missing",
remediation: `Configure a provider for required operation ${missing}.`,
};
const unavailable = requirements
.map((operation) => descriptors.get(operation))
.find((descriptor) => descriptor?.available === false);
if (unavailable !== undefined)
return {
reason:
unavailable.reason?.toLowerCase().includes("require macos") === true
? "unsupported_host"
: "provider_unavailable",
remediation:
unavailable.reason ?? "Choose another target or configured provider.",
};
return { reason: "available", remediation: null };
};
-119
View File
@@ -1,119 +0,0 @@
import { access, readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { parse as parseToml } from "smol-toml";
import { z } from "zod";
import { PRODUCT_IDENTITY } from "../identity.js";
import { supportedClients } from "./SupportedClients.js";
export interface ClientRegistrationStatus {
readonly client: string;
readonly config_path: string;
readonly command: readonly string[];
readonly state: "aligned" | "stale" | "missing" | "invalid";
readonly remediation: string | null;
}
const objectSchema = z.record(z.string(), z.unknown());
const registrationSchema = z
.object({ command: z.string().min(1), args: z.array(z.string()).default([]) })
.passthrough();
/** Inspect supported client registrations without reading their environment. */
export const readClientRegistrationStatuses = async (
home: string,
currentCommandPath: string = resolve(process.argv[1] ?? "unknown"),
): Promise<readonly ClientRegistrationStatus[]> => {
const statuses: ClientRegistrationStatus[] = [];
for (const client of supportedClients(home)) {
if (client.format === "unsupported" || !(await exists(client.markerPath)))
continue;
try {
const content = await readFile(client.configPath, "utf8");
const document = objectSchema.parse(
client.format === "toml" ? parseToml(content) : JSON.parse(content),
);
const servers = objectSchema.parse(
document[client.format === "toml" ? "mcp_servers" : "mcpServers"] ?? {},
);
const raw = servers[PRODUCT_IDENTITY.mcpServerKey];
if (raw === undefined) {
statuses.push(status(client.name, client.configPath, [], "missing"));
continue;
}
const registration = registrationSchema.parse(raw);
const command = [registration.command, ...registration.args];
statuses.push(
status(
client.name,
client.configPath,
command,
registrationAligned(command, currentCommandPath)
? "aligned"
: "stale",
),
);
} catch (cause: unknown) {
statuses.push(
status(
client.name,
client.configPath,
[],
isMissing(cause) ? "missing" : "invalid",
),
);
}
}
return statuses.sort((left, right) =>
left.client.localeCompare(right.client),
);
};
const registrationAligned = (
command: readonly string[],
currentCommandPath: string,
): boolean => {
if (
command.length === 4 &&
command[0] === "npx" &&
command[1] === "-y" &&
command[2] === PRODUCT_IDENTITY.packageName &&
command[3] === "mcp"
)
return true;
return (
command.length === 2 &&
command[1] === "mcp" &&
resolve(command[0] ?? "") === currentCommandPath
);
};
const status = (
client: string,
configPath: string,
command: readonly string[],
state: ClientRegistrationStatus["state"],
): ClientRegistrationStatus => ({
client,
config_path: configPath,
command,
state,
remediation:
state === "aligned"
? null
: "Run rea setup to refresh this registration, then restart the client.",
});
const exists = async (path: string | undefined): Promise<boolean> => {
if (path === undefined) return false;
try {
await access(path);
return true;
} catch {
return false;
}
};
const isMissing = (cause: unknown): boolean =>
cause instanceof Error && "code" in cause && cause.code === "ENOENT";
-22
View File
@@ -1,22 +0,0 @@
import { realpath } from "node:fs/promises";
/** Canonicalize configured policy roots while ignoring stale missing entries. */
export const canonicalizeConfiguredRoots = async (
roots: readonly string[],
): Promise<string[]> => {
const canonical: string[] = [];
for (const root of roots) {
try {
canonical.push(await realpath(root));
} catch (cause: unknown) {
if (!isMissingPath(cause)) throw cause;
}
}
return canonical;
};
const isMissingPath = (cause: unknown): boolean =>
typeof cause === "object" &&
cause !== null &&
"code" in cause &&
cause.code === "ENOENT";
+21 -64
View File
@@ -15,7 +15,6 @@ import { jsonValueSchema } from "../domain/jsonValue.js";
import { err, ok, type Result } from "../domain/result.js";
import {
paginateArtifactInventory,
type ArtifactIntegrityPolicy,
type ArtifactInventorySnapshot,
} from "./ArtifactInventory.js";
import { scanAuthorizedArtifactInventory } from "./AuthorizedArtifactInventory.js";
@@ -34,70 +33,29 @@ export interface InventoryEvidencePages {
readonly right: readonly Evidence[];
}
/** Injectable artifact scan boundary used by paired version inventory. */
export type VersionInventoryScanner = typeof scanAuthorizedArtifactInventory;
/** Inputs shared by the two scans in one version comparison. */
export interface VersionInventoryScanOptions {
readonly leftPath: string;
readonly rightPath: string;
readonly inputRoots: readonly string[];
readonly limits: ArtifactLimits;
readonly signal?: AbortSignal;
readonly integrity: ArtifactIntegrityPolicy;
}
/** Start both independent version scans while preserving left/right identity. */
export const scanVersionInventories = async (
options: VersionInventoryScanOptions,
scanner: VersionInventoryScanner = scanAuthorizedArtifactInventory,
): Promise<VersionSnapshots> => {
const [left, right] = await Promise.all([
scanner(
options.leftPath,
options.inputRoots,
options.limits,
options.signal,
options.integrity,
),
scanner(
options.rightPath,
options.inputRoots,
options.limits,
options.signal,
options.integrity,
),
]);
return { left, right };
};
/** Authorize and scan both inputs once under the same bounded traversal policy. */
export const scanVersions = async (
input: CrossVersionInvestigationInput,
inputRoots: readonly string[],
signal?: AbortSignal,
integrityContinueEnabled = false,
): Promise<Result<VersionSnapshots, AnalysisError>> => {
try {
if (signal?.aborted === true)
return err(new AnalysisCancelledError("find_changed_behavior"));
const limits = artifactLimits(input.options);
const integrity: ArtifactIntegrityPolicy = {
mode: input.integrity_policy,
approved: input.integrity_continue_approved,
enabled: integrityContinueEnabled,
maxMismatches: input.max_integrity_mismatches,
};
return ok(
await scanVersionInventories({
leftPath: input.left_path,
rightPath: input.right_path,
inputRoots,
limits,
...(signal === undefined ? {} : { signal }),
integrity,
}),
const left = await scanAuthorizedArtifactInventory(
input.left_path,
inputRoots,
limits,
signal,
);
const right = await scanAuthorizedArtifactInventory(
input.right_path,
inputRoots,
limits,
signal,
);
return ok({ left, right });
} catch (cause: unknown) {
if (cause instanceof ArtifactReaderFailure)
return err(
@@ -116,8 +74,12 @@ export const createInventoryEvidencePages = (
input: CrossVersionInvestigationInput,
snapshots: VersionSnapshots,
): Result<InventoryEvidencePages, AnalysisError> => {
const left = inventoryPages(input.left_path, snapshots.left, input);
const right = inventoryPages(input.right_path, snapshots.right, input);
const left = inventoryPages(input.left_path, snapshots.left, input.options);
const right = inventoryPages(
input.right_path,
snapshots.right,
input.options,
);
if (left.length > 100 || right.length > 100)
return err(new ArtifactOperationError("inventory_artifact", "limit"));
return ok({ left, right });
@@ -146,9 +108,8 @@ export const runLimitations = (snapshots: VersionSnapshots): string[] =>
const inventoryPages = (
path: string,
snapshot: ArtifactInventorySnapshot,
input: CrossVersionInvestigationInput,
options: InvestigationRunOptions,
): Evidence[] => {
const options = input.options;
const count = Math.max(
1,
Math.ceil(snapshot.nodes.length / options.page_size),
@@ -156,17 +117,16 @@ const inventoryPages = (
Math.ceil(snapshot.edges.length / options.page_size),
);
return Array.from({ length: count }, (_, index) =>
inventoryPage(path, snapshot, input, index * options.page_size),
inventoryPage(path, snapshot, options, index * options.page_size),
);
};
const inventoryPage = (
path: string,
snapshot: ArtifactInventorySnapshot,
input: CrossVersionInvestigationInput,
options: InvestigationRunOptions,
offset: number,
): Evidence => {
const options = input.options;
const result = paginateArtifactInventory(snapshot, {
nodeOffset: offset,
nodeLimit: options.page_size,
@@ -197,9 +157,6 @@ const inventoryPage = (
max_compression_ratio: options.max_compression_ratio,
max_depth: options.max_depth,
max_path_bytes: options.max_path_bytes,
integrity_policy: input.integrity_policy,
integrity_continue_approved: input.integrity_continue_approved,
max_integrity_mismatches: input.max_integrity_mismatches,
},
result: jsonValueSchema.parse(result),
rawResult: null,
+2 -104
View File
@@ -6,14 +6,12 @@ import {
type EvidenceFilePolicy,
} from "../domain/evidenceBundle.js";
import {
AnalysisCancelledError,
AnalysisInputError,
EvidenceIntegrityError,
InvestigationWorkspaceError,
type AnalysisError,
} from "../domain/errors.js";
import {
createLegacyInvestigationRunIdentity,
createInvestigationRunIdentity,
createInvestigationWorkspace,
crossVersionInvestigationInputSchema,
@@ -27,7 +25,6 @@ import {
import { jsonValueSchema } from "../domain/jsonValue.js";
import { err, ok, type Result } from "../domain/result.js";
import type { BinarySessionPort } from "./BinarySession.js";
import type { ProgressReporter } from "./ProgressReporter.js";
import {
AUTOMATIC_RUN_LIMITATION,
createInventoryEvidencePages,
@@ -45,7 +42,6 @@ import {
ARTIFACT_COMPARISON_PROVIDER,
CHANGED_BEHAVIOR_PROVIDER,
} from "./InvestigationProviders.js";
import { selectCompletedInvestigationReplay } from "./CrossVersionInvestigationReplay.js";
export interface CrossVersionInvestigationOutcome {
readonly evidence: Evidence;
@@ -58,10 +54,6 @@ export interface CrossVersionInvestigationExecution {
readonly inputRoots: readonly string[];
readonly session?: BinarySessionPort;
readonly signal?: AbortSignal;
readonly progress?: ProgressReporter;
readonly integrityContinueEnabled?: boolean;
readonly authorizeInputRead?: () => Promise<Result<null, AnalysisError>>;
readonly authorizeWorkspaceWrite?: () => Promise<Result<null, AnalysisError>>;
}
/** Run or resume a deterministic cross-version artifact investigation. */
@@ -84,54 +76,19 @@ export const runCrossVersionInvestigation = async (
if (!initial.ok) return initial;
const preflight = validateWorkspaceRequest(initial.value, parsed.data);
if (!preflight.ok) return preflight;
if (isCancelled(execution.signal))
return err(new AnalysisCancelledError("find_changed_behavior"));
if (parsed.data.replay_run_id !== undefined) {
const replay = selectCompletedInvestigationReplay(
initial.value,
parsed.data,
);
if (!replay.ok) return replay;
return completeOutcome(
replay.value.workspace,
replay.value.run,
true,
execution.session,
);
}
if (execution.authorizeInputRead !== undefined) {
const authorized = await execution.authorizeInputRead();
if (!authorized.ok) return authorized;
}
await execution.progress?.report({
phase: "scan_versions",
completed: 0,
total: 4,
message: "Scanning both version inputs",
});
const snapshots = await scanVersions(
parsed.data,
execution.inputRoots,
execution.signal,
execution.integrityContinueEnabled,
);
if (!snapshots.ok) return snapshots;
await execution.progress?.report({
phase: "scan_versions",
completed: 1,
total: 4,
message: "Version inputs scanned",
});
return continueInvestigation({
input: parsed.data,
initial: initial.value,
snapshots: snapshots.value,
policy,
session: execution.session,
signal: execution.signal,
progress: execution.progress,
authorizeWorkspaceWrite: execution.authorizeWorkspaceWrite,
});
};
@@ -146,11 +103,6 @@ const continueInvestigation = async (context: {
readonly snapshots: VersionSnapshots;
readonly policy: EvidenceFilePolicy;
readonly session: BinarySessionPort | undefined;
readonly signal: AbortSignal | undefined;
readonly progress: ProgressReporter | undefined;
readonly authorizeWorkspaceWrite:
| (() => Promise<Result<null, AnalysisError>>)
| undefined;
}): Promise<Result<CrossVersionInvestigationOutcome, AnalysisError>> => {
const left = targetFor(context.snapshots.left);
const right = targetFor(context.snapshots.right);
@@ -158,35 +110,14 @@ const continueInvestigation = async (context: {
left,
right,
options: context.input.options,
integrity_policy: context.input.integrity_policy,
integrity_continue_approved: context.input.integrity_continue_approved,
max_integrity_mismatches: context.input.max_integrity_mismatches,
});
const legacyIdentity = createLegacyInvestigationRunIdentity({
left,
right,
options: context.input.options,
});
const existing = context.initial?.runs.find(
(run) =>
run.run_id === identity.runId ||
(context.input.integrity_policy === "fail" &&
!context.input.integrity_continue_approved &&
context.input.max_integrity_mismatches === 10 &&
"legacy_request_identity" in run &&
run.max_integrity_mismatches === 10 &&
run.run_id === legacyIdentity.runId),
({ run_id: id }) => id === identity.runId,
);
if (context.initial !== null && existing?.status === "complete")
return completeOutcome(context.initial, existing, true, context.session);
if (context.authorizeWorkspaceWrite !== undefined) {
const authorized = await context.authorizeWorkspaceWrite();
if (!authorized.ok) return authorized;
}
const pages = createInventoryEvidencePages(context.input, context.snapshots);
if (!pages.ok) return pages;
if (isCancelled(context.signal))
return err(new AnalysisCancelledError("find_changed_behavior"));
const inventoried = await ensureInventoryCheckpoint({
input: context.input,
current: context.initial,
@@ -196,47 +127,20 @@ const continueInvestigation = async (context: {
policy: context.policy,
});
if (!inventoried.ok) return inventoried;
await context.progress?.report({
phase: "inventory_versions",
completed: 2,
total: 4,
message: "Both artifact inventories checkpointed",
});
if (isCancelled(context.signal))
return err(new AnalysisCancelledError("find_changed_behavior"));
const compared = await ensureComparisonCheckpoint(
context.input,
inventoried.value,
context.policy,
);
if (!compared.ok) return compared;
await context.progress?.report({
phase: "compare_artifacts",
completed: 3,
total: 4,
message: "Artifact comparison checkpointed",
});
if (isCancelled(context.signal))
return err(new AnalysisCancelledError("find_changed_behavior"));
const finalized = await finalizeInvestigation(
return finalizeInvestigation(
context.input,
compared.value,
context.policy,
context.session,
);
await context.progress?.report({
phase: "changed_behavior",
completed: 4,
total: 4,
message: finalized.ok ? "Investigation completed" : "Investigation failed",
terminal: true,
});
return finalized;
};
const isCancelled = (signal: AbortSignal | undefined): boolean =>
signal?.aborted === true;
const ensureInventoryCheckpoint = async (input: {
readonly input: CrossVersionInvestigationInput;
readonly current: InvestigationWorkspace | null;
@@ -258,9 +162,6 @@ const ensureInventoryCheckpoint = async (input: {
left,
right,
options: input.input.options,
integrity_policy: input.input.integrity_policy,
integrity_continue_approved: input.input.integrity_continue_approved,
max_integrity_mismatches: input.input.max_integrity_mismatches,
});
const run = investigationRunSchema.parse({
schema_version: 1,
@@ -269,9 +170,6 @@ const ensureInventoryCheckpoint = async (input: {
left,
right,
options: input.input.options,
integrity_policy: input.input.integrity_policy,
integrity_continue_approved: input.input.integrity_continue_approved,
max_integrity_mismatches: input.input.max_integrity_mismatches,
status: "running",
completed_stages: ["inventory_left", "inventory_right"],
left_inventory_evidence_ids: evidenceIds(input.pages.left),
@@ -1,372 +0,0 @@
import {
changedBehaviorResultSchema,
type ChangedBehaviorResult,
} from "../domain/changedBehavior.js";
import { artifactInventoryResultSchema } from "../domain/artifactGraph.js";
import { parseArtifactInventoryEvidence } from "../domain/artifactInventoryEvidence.js";
import type { Evidence } from "../domain/evidence.js";
import {
EvidenceIntegrityError,
InvestigationWorkspaceError,
} from "../domain/errors.js";
import type {
CrossVersionInvestigationInput,
InvestigationRun,
InvestigationWorkspace,
} from "../domain/investigationWorkspace.js";
import { err, ok, type Result } from "../domain/result.js";
import {
ARTIFACT_COMPARISON_PROVIDER,
ARTIFACT_GRAPH_PROVIDER,
CHANGED_BEHAVIOR_PROVIDER,
} from "./InvestigationProviders.js";
/** Validated workspace state returned by an explicit replay selector. */
export interface CompletedInvestigationReplay {
readonly workspace: InvestigationWorkspace;
readonly run: InvestigationRun;
}
/** Select and verify one caller-identified complete run without input access. */
export const selectCompletedInvestigationReplay = (
workspace: InvestigationWorkspace | null,
input: CrossVersionInvestigationInput,
): Result<
CompletedInvestigationReplay,
InvestigationWorkspaceError | EvidenceIntegrityError
> => {
const run = workspace?.runs.find(
({ run_id: runId }) => runId === input.replay_run_id,
);
if (workspace === null || run === undefined || run.status !== "complete")
return err(new InvestigationWorkspaceError("read", "revision-conflict"));
if (!replayRequestMatches(workspace, run, input))
return err(new InvestigationWorkspaceError("read", "revision-conflict"));
const evidence = validateCompletedReplayEvidence(workspace, run);
return evidence.ok ? ok({ workspace, run }) : evidence;
};
const replayRequestMatches = (
workspace: InvestigationWorkspace,
run: InvestigationRun,
input: CrossVersionInvestigationInput,
): boolean =>
JSON.stringify(run.options) === JSON.stringify(input.options) &&
run.integrity_policy === input.integrity_policy &&
run.integrity_continue_approved === input.integrity_continue_approved &&
run.max_integrity_mismatches === input.max_integrity_mismatches &&
inventoryEvidenceMatches(workspace, run.left_inventory_evidence_ids, {
path: input.left_path,
target: run.left,
input,
}) &&
inventoryEvidenceMatches(workspace, run.right_inventory_evidence_ids, {
path: input.right_path,
target: run.right,
input,
});
interface InventoryReplayExpectation {
readonly path: string;
readonly target: InvestigationRun["left"];
readonly input: CrossVersionInvestigationInput;
}
const inventoryEvidenceMatches = (
workspace: InvestigationWorkspace,
evidenceIds: readonly string[],
expectation: InventoryReplayExpectation,
): boolean => {
const byId = new Map(
workspace.bundle.records.map((record) => [record.evidence_id, record]),
);
const evidence = evidenceIds.flatMap((evidenceId) => {
const record = byId.get(evidenceId);
return record === undefined ? [] : [record];
});
if (
evidence.length !== evidenceIds.length ||
!evidence.every((record, index) =>
inventoryPageMatches(record, index, expectation),
)
)
return false;
try {
const inventory = parseArtifactInventoryEvidence(evidence).inventory;
return (
inventory.complete &&
evidence.length ===
expectedInventoryPageCount(
inventory.manifest.node_count,
inventory.manifest.occurrence_count,
inventory.manifest.edge_count,
expectation.input.options.page_size,
) &&
inventory.manifest.root_sha256 === expectation.target.root_sha256 &&
inventory.manifest.graph_sha256 === expectation.target.graph_sha256 &&
inventory.manifest.manifest_id === expectation.target.manifest_id &&
inventory.manifest.root_format === expectation.target.format
);
} catch {
return false;
}
};
const inventoryPageMatches = (
evidence: Evidence,
index: number,
expectation: InventoryReplayExpectation,
): boolean => {
const page = artifactInventoryResultSchema.safeParse(
evidence.normalized_result,
);
if (!page.success) return false;
const offset = index * expectation.input.options.page_size;
return (
inventoryEvidenceMetadataMatches(evidence, expectation) &&
inventoryParametersMatch(evidence.parameters, offset, expectation.input) &&
inventoryResultPageMatches(page.data, offset, expectation.input)
);
};
const inventoryEvidenceMetadataMatches = (
evidence: Evidence,
expectation: InventoryReplayExpectation,
): boolean =>
evidence.operation === "inventory_artifact" &&
evidence.predicate_type === "rea.analysis/v2" &&
providerMatches(evidence.provider, ARTIFACT_GRAPH_PROVIDER) &&
evidence.subject?.local_path === expectation.path &&
evidence.subject.digest.sha256 === expectation.target.root_sha256 &&
evidence.subject.format === expectation.target.format &&
evidence.confidence === "observed" &&
evidence.authority === "shipped-artifact" &&
evidence.raw_result === null &&
evidence.environment === null &&
evidence.evidence_links.length === 0;
const inventoryResultPageMatches = (
page: ReturnType<typeof artifactInventoryResultSchema.parse>,
offset: number,
input: CrossVersionInvestigationInput,
): boolean =>
collectionPageMatches(
page.nodes,
offset,
input.options.page_size,
page.manifest.node_count,
) &&
collectionPageMatches(
page.occurrences,
offset,
input.options.page_size,
page.manifest.occurrence_count,
) &&
collectionPageMatches(
page.edges,
offset,
input.options.page_size,
page.manifest.edge_count,
) &&
valuesMatch(page.limits, traversalLimits(input));
const collectionPageMatches = (
page: {
readonly items: readonly unknown[];
readonly offset: number;
readonly limit: number;
readonly total: number;
readonly next_offset: number | null;
},
offset: number,
limit: number,
total: number,
): boolean => {
const expectedItems = Math.max(0, Math.min(limit, total - offset));
const nextOffset = offset + limit < total ? offset + limit : null;
return (
page.offset === offset &&
page.limit === limit &&
page.total === total &&
page.items.length === expectedItems &&
page.next_offset === nextOffset
);
};
const expectedInventoryPageCount = (
nodeCount: number,
occurrenceCount: number,
edgeCount: number,
pageSize: number,
): number =>
Math.max(
1,
Math.ceil(nodeCount / pageSize),
Math.ceil(occurrenceCount / pageSize),
Math.ceil(edgeCount / pageSize),
);
const inventoryParametersMatch = (
parameters: Readonly<Record<string, unknown>>,
offset: number,
input: CrossVersionInvestigationInput,
): boolean =>
Object.keys(parameters).length === 15 &&
parameters.node_offset === offset &&
parameters.node_limit === input.options.page_size &&
parameters.occurrence_offset === offset &&
parameters.occurrence_limit === input.options.page_size &&
parameters.edge_offset === offset &&
parameters.edge_limit === input.options.page_size &&
parameters.max_entries === input.options.max_entries &&
parameters.max_total_bytes === input.options.max_total_bytes &&
parameters.max_entry_bytes === input.options.max_entry_bytes &&
parameters.max_compression_ratio === input.options.max_compression_ratio &&
parameters.max_depth === input.options.max_depth &&
parameters.max_path_bytes === input.options.max_path_bytes &&
parameters.integrity_policy === input.integrity_policy &&
parameters.integrity_continue_approved ===
input.integrity_continue_approved &&
parameters.max_integrity_mismatches === input.max_integrity_mismatches;
const traversalLimits = (input: CrossVersionInvestigationInput) => ({
max_entries: input.options.max_entries,
max_total_bytes: input.options.max_total_bytes,
max_entry_bytes: input.options.max_entry_bytes,
max_compression_ratio: input.options.max_compression_ratio,
max_depth: input.options.max_depth,
max_path_bytes: input.options.max_path_bytes,
});
const validateCompletedReplayEvidence = (
workspace: InvestigationWorkspace,
run: InvestigationRun,
): Result<null, EvidenceIntegrityError> => {
const inventoryIds = [
...run.left_inventory_evidence_ids,
...run.right_inventory_evidence_ids,
];
const comparison = workspace.bundle.records.find(
({ evidence_id: evidenceId }) => evidenceId === run.comparison_evidence_id,
);
if (!comparisonEvidenceMatches(comparison, inventoryIds, run))
return inconsistentReplayEvidence();
const resultLinks = [
...new Set([comparison.evidence_id, ...inventoryIds]),
].sort((left, right) => left.localeCompare(right, "en"));
const result = workspace.bundle.records.find(
({ evidence_id: evidenceId }) => evidenceId === run.result_evidence_id,
);
if (!resultEvidenceMatches(result, resultLinks, workspace, run))
return inconsistentReplayEvidence();
const parsedResult = changedBehaviorResultSchema.safeParse(
result.normalized_result,
);
if (
!parsedResult.success ||
!resultPayloadMatches(parsedResult.data, resultLinks, workspace, run)
)
return inconsistentReplayEvidence();
return ok(null);
};
const comparisonEvidenceMatches = (
comparison: Evidence | undefined,
inventoryIds: readonly string[],
run: InvestigationRun,
): comparison is Evidence =>
comparison?.operation === "compare_artifacts" &&
comparison.predicate_type === "rea.artifact-comparison/v1" &&
providerMatches(comparison.provider, ARTIFACT_COMPARISON_PROVIDER) &&
comparison.subject === null &&
comparison.confidence === "derived" &&
comparison.authority === "analyst-inference" &&
valuesMatch(comparison.evidence_links, inventoryIds) &&
comparisonParametersMatch(comparison.parameters, run);
const resultEvidenceMatches = (
result: Evidence | undefined,
resultLinks: readonly string[],
workspace: InvestigationWorkspace,
run: InvestigationRun,
): result is Evidence =>
result?.operation === "find_changed_behavior" &&
result.predicate_type === "rea.changed-behavior/v1" &&
providerMatches(result.provider, CHANGED_BEHAVIOR_PROVIDER) &&
result.subject === null &&
result.confidence === "derived" &&
result.authority === "analyst-inference" &&
valuesMatch(result.evidence_links, resultLinks) &&
resultParametersMatch(result.parameters, workspace, run);
const resultPayloadMatches = (
result: ChangedBehaviorResult,
resultLinks: readonly string[],
workspace: InvestigationWorkspace,
run: InvestigationRun,
): boolean => {
const summary = result.investigation_run;
return (
summary?.workspace_id === workspace.workspace_id &&
summary.run_id === run.run_id &&
summary.left_manifest_id === run.left.manifest_id &&
summary.right_manifest_id === run.right.manifest_id &&
summary.inventory_evidence_count ===
run.left_inventory_evidence_ids.length +
run.right_inventory_evidence_ids.length &&
summary.comparison_evidence_id === run.comparison_evidence_id &&
valuesMatch(summary.limitations, run.limitations) &&
valuesMatch(result.evidence_links, resultLinks)
);
};
const inconsistentReplayEvidence = (): Result<never, EvidenceIntegrityError> =>
err(
new EvidenceIntegrityError(
"Completed investigation replay Evidence is inconsistent",
),
);
const comparisonParametersMatch = (
parameters: Readonly<Record<string, unknown>>,
run: InvestigationRun,
): boolean =>
Object.keys(parameters).length === 4 &&
JSON.stringify(parameters.left_evidence_ids) ===
JSON.stringify(run.left_inventory_evidence_ids) &&
JSON.stringify(parameters.right_evidence_ids) ===
JSON.stringify(run.right_inventory_evidence_ids) &&
parameters.offset === 0 &&
parameters.limit === run.options.change_limit;
const resultParametersMatch = (
parameters: Readonly<Record<string, unknown>>,
workspace: InvestigationWorkspace,
run: InvestigationRun,
): boolean =>
Object.keys(parameters).length === 5 &&
parameters.workspace_id === workspace.workspace_id &&
parameters.run_id === run.run_id &&
JSON.stringify(parameters.comparison_evidence_ids) ===
JSON.stringify([run.comparison_evidence_id]) &&
parameters.offset === 0 &&
parameters.limit === Math.min(run.options.change_limit, 100);
const providerMatches = (
actual: {
readonly id: string;
readonly name: string;
readonly version: string | null;
},
expected: {
readonly id: string;
readonly name: string;
readonly version: string | null;
},
): boolean =>
actual.id === expected.id &&
actual.name === expected.name &&
actual.version === expected.version;
const valuesMatch = (left: unknown, right: unknown): boolean =>
JSON.stringify(left) === JSON.stringify(right);
@@ -1,105 +0,0 @@
import {
AnalysisProtocolError,
PermissionRequiredError,
type AnalysisError,
} from "../domain/errors.js";
import type { PermissionCapability } from "../domain/permissionPolicy.js";
import { err, ok, type Result } from "../domain/result.js";
import {
PermissionPathError,
type PermissionAuthority,
} from "./PermissionAuthority.js";
interface RootPermissionRequest {
readonly capability: PermissionCapability;
readonly roots: readonly string[];
readonly access: "read" | "write";
readonly operation: string;
}
/** Authorize one root-scoped operation and project policy path failures. */
export const authorizeRootPermission = async (
authority: PermissionAuthority,
request: RootPermissionRequest,
): Promise<Result<null, AnalysisError>> => {
const authorized = await authority.authorize(
{
capability: request.capability,
roots: request.roots,
executables: [],
environment_names: [],
network: "none",
mount: false,
operation_identity: `${request.operation}:${request.capability}:${request.roots.join(":")}`,
},
request.access,
);
return authorized.ok
? ok(null)
: err(
authorized.error instanceof PermissionRequiredError
? authorized.error
: new AnalysisProtocolError(authorized.error.message, {
cause: authorized.error,
}),
);
};
export interface DeferredFileWriteAuthorization {
readonly authorizeWrite: () => Promise<Result<null, AnalysisError>>;
}
/** Authorize an existing file read and defer write authority until requested. */
export const authorizeFileReadWithDeferredWrite = async (
authority: PermissionAuthority,
input: {
readonly path: string;
readonly readCapability: PermissionCapability;
readonly writeCapability: PermissionCapability;
readonly operation: string;
},
): Promise<Result<DeferredFileWriteAuthorization, AnalysisError>> => {
const read = await authority.authorize(
{
capability: input.readCapability,
roots: [input.path],
executables: [],
environment_names: [],
network: "none",
mount: false,
operation_identity: `${input.operation}:${input.readCapability}:${input.path}`,
},
"read",
);
let writeAuthorization: Promise<Result<null, AnalysisError>> | undefined;
if (!read.ok) {
if (
!(read.error instanceof PermissionPathError) ||
read.error.reason !== "not_found"
)
return err(
read.error instanceof PermissionRequiredError
? read.error
: new AnalysisProtocolError(read.error.message, {
cause: read.error,
}),
);
writeAuthorization = authorizeRootPermission(authority, {
capability: input.writeCapability,
roots: [input.path],
access: "write",
operation: input.operation,
});
const authorized = await writeAuthorization;
if (!authorized.ok) return authorized;
}
return ok({
authorizeWrite: () =>
(writeAuthorization ??= authorizeRootPermission(authority, {
capability: input.writeCapability,
roots: [input.path],
access: "write",
operation: input.operation,
})),
});
};
+11 -190
View File
@@ -1,6 +1,4 @@
import { parseConfig, type AppConfig } from "../config.js";
import { jsonObjectSchema } from "../domain/jsonValue.js";
import { createServerIdentity } from "../serverIdentity.js";
import { parseConfig } from "../config.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { EnhancedTools } from "./EnhancedTools.js";
import { createBinarySession } from "./runtime.js";
@@ -11,8 +9,6 @@ import type { NativeToolName } from "../contracts/nativeToolContracts.js";
import type { ArtifactToolName } from "../contracts/artifactToolContracts.js";
import {
EvidenceIntegrityError,
AnalysisProtocolError,
PermissionRequiredError,
projectAnalysisError,
type AnalysisError,
} from "../domain/errors.js";
@@ -28,12 +24,6 @@ import {
} from "../domain/analysisSnapshot.js";
import { err, ok, type Result } from "../domain/result.js";
import type { AnalysisSnapshot } from "../domain/analysisSnapshot.js";
import { loadConfiguredPermissionAuthority } from "./PermissionConfiguration.js";
import type { PermissionAuthority } from "./PermissionAuthority.js";
import {
authorizeFileReadWithDeferredWrite,
type DeferredFileWriteAuthorization,
} from "./DeferredFileAuthorization.js";
const WORKFLOW_PROVIDER = {
id: "rea-workflow",
@@ -62,20 +52,12 @@ export const runDirectAnalysis = async (
options: {
readonly logger?: Logger;
readonly snapshotPath?: string | undefined;
readonly signal?: AbortSignal;
readonly permissionAuthority?: PermissionAuthority;
} = {},
): Promise<JsonValue> =>
withProcessCancellation(options.signal, (signal) =>
runAnalysis(path, tool, arguments_, {
logger: options.logger ?? silentLogger,
snapshotPath: options.snapshotPath,
signal,
...(options.permissionAuthority === undefined
? {}
: { permissionAuthority: options.permissionAuthority }),
}),
);
runAnalysis(path, tool, arguments_, {
logger: options.logger ?? silentLogger,
snapshotPath: options.snapshotPath,
});
/** Execute one provider-native semantic operation with atomic provenance. */
export const runProviderAnalysis = async (
@@ -83,15 +65,8 @@ export const runProviderAnalysis = async (
tool: NativeToolName | ArtifactToolName,
arguments_: Readonly<Record<string, JsonValue>>,
logger: Logger = silentLogger,
signal?: AbortSignal,
): Promise<JsonValue> =>
withProcessCancellation(signal, (operationSignal) =>
runAnalysis(path, tool, arguments_, {
logger,
snapshotPath: undefined,
signal: operationSignal,
}),
);
runAnalysis(path, tool, arguments_, { logger, snapshotPath: undefined });
/** Describe configured providers without opening a target or launching Hopper. */
export const runSessionStatus = async (
@@ -101,135 +76,12 @@ export const runSessionStatus = async (
if (!config.ok) return cliError(config.error);
const session = createBinarySession(config.value, logger);
try {
return {
...jsonObjectSchema.parse(session.status()),
server_identity: createServerIdentity({
startedAt: new Date().toISOString(),
}),
};
return session.status();
} finally {
await session.close();
}
};
const authorizeAnalysis = async (
authority: PermissionAuthority,
tool: NativeToolName | ArtifactToolName | DirectAnalysisTool,
arguments_: Readonly<Record<string, JsonValue>>,
): Promise<Result<null, AnalysisError>> => {
const requests = [];
if (tool === "extract_artifact" && typeof arguments_.output_root === "string")
requests.push({
capability: "artifact_extract" as const,
path: arguments_.output_root,
access: "write" as const,
});
for (const request of requests) {
const result = await authority.authorize(
{
capability: request.capability,
roots: [request.path],
executables: [],
environment_names: [],
network: "none",
mount: false,
operation_identity: `${tool}:${request.capability}:${request.path}`,
},
request.access,
);
if (!result.ok)
return err(
result.error instanceof PermissionRequiredError
? result.error
: new AnalysisProtocolError(result.error.message, {
cause: result.error,
}),
);
}
if (
tool === "inventory_artifact" &&
arguments_.native_mount_approved === true
) {
const result = await authority.authorize(
{
capability: "native_mount",
roots: [],
executables: [],
environment_names: [],
network: "none",
mount: true,
operation_identity: "inventory_artifact:native_mount",
},
"read",
);
if (!result.ok)
return err(
result.error instanceof PermissionRequiredError
? result.error
: new AnalysisProtocolError(result.error.message, {
cause: result.error,
}),
);
}
return ok(null);
};
const permissionAuthorityFor = (
config: AppConfig,
supplied: PermissionAuthority | undefined,
): ReturnType<typeof loadConfiguredPermissionAuthority> =>
supplied === undefined
? loadConfiguredPermissionAuthority(config)
: Promise.resolve(ok(supplied));
const authorizeSnapshotAccess = async (
authority: PermissionAuthority,
tool: NativeToolName | ArtifactToolName | DirectAnalysisTool,
snapshotPath: string | undefined,
): Promise<
Result<DeferredFileWriteAuthorization | undefined, AnalysisError>
> =>
snapshotPath === undefined
? ok(undefined)
: authorizeFileReadWithDeferredWrite(authority, {
path: snapshotPath,
readCapability: "snapshot_read",
writeCapability: "snapshot_write",
operation: tool,
});
const authorizeDeferredWrite = (
authorization: DeferredFileWriteAuthorization | undefined,
): Promise<Result<null, AnalysisError>> =>
authorization?.authorizeWrite() ?? Promise.resolve(ok(null));
const authorizeAnalysisRun = async (input: {
readonly config: AppConfig;
readonly suppliedAuthority: PermissionAuthority | undefined;
readonly tool: NativeToolName | ArtifactToolName | DirectAnalysisTool;
readonly arguments: Readonly<Record<string, JsonValue>>;
readonly snapshotPath: string | undefined;
}): Promise<
Result<DeferredFileWriteAuthorization | undefined, AnalysisError>
> => {
const authority = await permissionAuthorityFor(
input.config,
input.suppliedAuthority,
);
if (!authority.ok) return authority;
const operation = await authorizeAnalysis(
authority.value,
input.tool,
input.arguments,
);
if (!operation.ok) return operation;
return authorizeSnapshotAccess(
authority.value,
input.tool,
input.snapshotPath,
);
};
const runAnalysis = async (
path: string,
tool: NativeToolName | ArtifactToolName | DirectAnalysisTool,
@@ -237,21 +89,11 @@ const runAnalysis = async (
options: {
readonly logger: Logger;
readonly snapshotPath: string | undefined;
readonly signal: AbortSignal;
readonly permissionAuthority?: PermissionAuthority;
},
): Promise<JsonValue> => {
const { logger, signal, snapshotPath } = options;
const { logger, snapshotPath } = options;
const config = parseConfig(process.env);
if (!config.ok) return cliError(config.error);
const authorization = await authorizeAnalysisRun({
config: config.value,
suppliedAuthority: options.permissionAuthority,
tool,
arguments: arguments_,
snapshotPath,
});
if (!authorization.ok) return cliError(authorization.error);
const unavailable = snapshotUnavailable(
snapshotPath,
config.value.hopperLoaderArgs,
@@ -269,12 +111,10 @@ const runAnalysis = async (
});
if (!prepared.ok) return cliError(prepared.error);
if (prepared.value.evidence !== undefined) return prepared.value.evidence;
const writable = await authorizeDeferredWrite(authorization.value);
if (!writable.ok) return cliError(writable.error);
const { snapshot } = prepared.value;
const opened = await session.open(
path,
snapshot === undefined ? { signal } : { signal, snapshot },
snapshot === undefined ? {} : { snapshot },
);
if (!opened.ok) return cliError(opened.error);
let output: JsonValue;
@@ -284,11 +124,7 @@ const runAnalysis = async (
tool === "analyze_function" ||
tool === "trace_feature"
) {
const result = await new EnhancedTools(session).execute(
tool,
arguments_,
signal,
);
const result = await new EnhancedTools(session).execute(tool, arguments_);
if (!result.ok) output = cliError(result.error);
else {
evidence = createEvidence(
@@ -307,7 +143,7 @@ const runAnalysis = async (
output = evidence;
}
} else {
const result = await session.execute(tool, arguments_, { signal });
const result = await session.execute(tool, arguments_);
if (!result.ok) output = cliError(result.error);
else {
evidence = createEvidence(
@@ -343,21 +179,6 @@ const runAnalysis = async (
}
};
const withProcessCancellation = async <Value>(
suppliedSignal: AbortSignal | undefined,
operation: (signal: AbortSignal) => Promise<Value>,
): Promise<Value> => {
if (suppliedSignal !== undefined) return operation(suppliedSignal);
const controller = new AbortController();
const cancel = (): void => controller.abort();
process.once("SIGINT", cancel);
try {
return await operation(controller.signal);
} finally {
process.off("SIGINT", cancel);
}
};
const prepareSnapshot = async (options: {
readonly path: string;
readonly tool: string;
+2 -113
View File
@@ -1,5 +1,6 @@
import { constants } from "node:fs";
import { access, readFile, readdir } from "node:fs/promises";
import { access } from "node:fs/promises";
import { readdir } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import { execFile } from "node:child_process";
@@ -15,12 +16,6 @@ import {
readLinuxDistribution,
type LinuxDistribution,
} from "./LinuxHopper.js";
import { CATALOG_IDENTITY } from "../catalogIdentity.js";
import { PRODUCT_IDENTITY, SDK_IDENTITY } from "../identity.js";
import {
readClientRegistrationStatuses,
type ClientRegistrationStatus,
} from "./ClientRegistrationStatus.js";
const execFileAsync = promisify(execFile);
const DEFAULT_HOPPER =
@@ -53,9 +48,6 @@ export interface DoctorHost {
linuxDemoRuntimeReady(): Promise<boolean>;
brewHopperPath(): Promise<string | undefined>;
manualHopperPaths(): Promise<readonly string[]>;
installationPaths?(): Promise<readonly string[]>;
installedSkillVersion?(): Promise<string | undefined>;
clientRegistrations?(): Promise<readonly ClientRegistrationStatus[]>;
}
/**
@@ -70,26 +62,6 @@ export const runDoctor = async (
readonly healthy: boolean;
readonly hopperPath?: string;
readonly checks: readonly DoctorCheck[];
readonly identity?: {
readonly cli_package_version: string;
readonly expected_skill_version: string;
readonly sdk: typeof SDK_IDENTITY;
readonly catalog: typeof CATALOG_IDENTITY;
readonly live_server: {
readonly state: "unknown";
readonly remediation: string;
};
readonly installations: {
readonly paths: readonly string[];
readonly state: "single" | "multiple" | "unknown";
};
readonly skill: {
readonly installed_version: string | null;
readonly state: "aligned" | "stale" | "unknown";
readonly remediation: string | null;
};
readonly registrations: readonly ClientRegistrationStatus[];
};
}> => {
const checks: DoctorCheck[] = [];
checks.push(
@@ -176,59 +148,10 @@ export const runDoctor = async (
classification: "config_drift",
}),
);
const installationPaths = (await host.installationPaths?.()) ?? [];
const installedSkillVersion = await host.installedSkillVersion?.();
const registrations = (await host.clientRegistrations?.()) ?? [];
for (const registration of registrations)
if (registration.state !== "aligned")
checks.push({
name: `registration:${registration.client}`,
ok: false,
classification: "config_drift",
detail: registration.config_path,
remediation:
registration.remediation ??
"Run rea setup, then restart the affected client.",
});
return {
healthy: checks.every(({ ok }) => ok),
...(hopperPath === undefined ? {} : { hopperPath }),
checks,
identity: {
cli_package_version: PRODUCT_IDENTITY.packageVersion,
expected_skill_version: PRODUCT_IDENTITY.skillVersion,
sdk: SDK_IDENTITY,
catalog: CATALOG_IDENTITY,
live_server: {
state: "unknown",
remediation:
"Compare rea://server/identity or binary_session from the active client; an on-disk registration cannot prove the running server version.",
},
installations: {
paths: installationPaths,
state:
installationPaths.length === 0
? "unknown"
: installationPaths.length === 1
? "single"
: "multiple",
},
skill: {
installed_version: installedSkillVersion ?? null,
state:
installedSkillVersion === undefined
? "unknown"
: installedSkillVersion === PRODUCT_IDENTITY.skillVersion
? "aligned"
: "stale",
remediation:
installedSkillVersion === undefined ||
installedSkillVersion === PRODUCT_IDENTITY.skillVersion
? null
: "Run rea setup to update the installed REA skill.",
},
registrations,
},
};
};
@@ -318,44 +241,10 @@ export const systemDoctorHost = (): DoctorHost => ({
}
return paths;
},
async installationPaths() {
try {
const command = process.platform === "win32" ? "where" : "which";
const arguments_ = process.platform === "win32" ? ["rea"] : ["-a", "rea"];
return uniqueLines((await execFileAsync(command, arguments_)).stdout);
} catch {
return [];
}
},
async installedSkillVersion() {
try {
const content = await readFile(
join(
homedir(),
".agents/skills",
PRODUCT_IDENTITY.skillName,
"SKILL.md",
),
"utf8",
);
return /^\s{2}version:\s*"([^"]+)"\s*$/mu.exec(content)?.[1];
} catch {
return undefined;
}
},
clientRegistrations: () => readClientRegistrationStatuses(homedir()),
});
const parseMajor = (version: string): number =>
Number.parseInt(version.split(".")[0] ?? "0", 10);
const uniqueLines = (value: string): string[] => [
...new Set(
value
.split(/\r?\n/u)
.map((line) => line.trim())
.filter((line) => line.length > 0),
),
];
const check = (
name: string,
ok: boolean,
-60
View File
@@ -1,60 +0,0 @@
import type { ProviderIdentity } from "./AnalysisProvider.js";
import {
createEvidence,
type Evidence,
type EvidenceObservation,
} from "../domain/evidence.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import type {
ElectronPageInspection,
ElectronTargetList,
InspectElectronPageInput,
ListElectronTargetsInput,
} from "../domain/electronObservation.js";
type ElectronOperation = "list_electron_targets" | "inspect_electron_page";
/** Create Evidence v2 for one root-confined Electron observation. */
export const createElectronEvidence = (
operation: ElectronOperation,
input: ListElectronTargetsInput | InspectElectronPageInput,
result: ElectronTargetList | ElectronPageInspection,
provider: ProviderIdentity,
): Evidence =>
createEvidence(undefined, provider, {
predicateType:
operation === "list_electron_targets"
? "rea.electron-target-list/v1"
: "rea.electron-page-inspection/v1",
operation,
parameters: parameters(input),
result: jsonValueSchema.parse(result),
confidence: "observed",
authority: "external-service",
environment:
"target" in result
? {
id: `${result.browser.product}@${result.browser.revision}`,
platform: process.platform,
architecture: process.arch,
isolation: "none",
}
: null,
limitations: result.limitations,
});
const parameters = (
input: ListElectronTargetsInput | InspectElectronPageInput,
): EvidenceObservation["parameters"] => ({
cdp_endpoint: input.cdp_endpoint,
allowed_file_roots: input.allowed_file_roots,
...("target_id" in input
? {
target_id: input.target_id,
observation_ms: input.observation_ms,
include_script_sources: input.include_script_sources,
source_capture_approved: input.source_capture_approved,
limits: input.limits,
}
: { offset: input.offset, limit: input.limit }),
});
@@ -1,22 +0,0 @@
import type { ExecutionOptions, ProviderIdentity } from "./AnalysisProvider.js";
import type { AnalysisError } from "../domain/errors.js";
import type {
ElectronPageInspection,
ElectronTargetList,
InspectElectronPageInput,
ListElectronTargetsInput,
} from "../domain/electronObservation.js";
import type { Result } from "../domain/result.js";
/** Provider-neutral boundary for root-confined Electron file-page observation. */
export interface ElectronObservationPort {
identity(): ProviderIdentity;
listTargets(
input: ListElectronTargetsInput,
options?: ExecutionOptions,
): Promise<Result<ElectronTargetList, AnalysisError>>;
inspectPage(
input: InspectElectronPageInput,
options?: ExecutionOptions,
): Promise<Result<ElectronPageInspection, AnalysisError>>;
}
@@ -1,116 +0,0 @@
import type { ExecutionOptions } from "./AnalysisProvider.js";
import type { ElectronObservationPort } from "./ElectronObservationPort.js";
import type { PermissionAuthority } from "./PermissionAuthority.js";
import type { Evidence } from "../domain/evidence.js";
import type {
InspectElectronPageInput,
ListElectronTargetsInput,
} from "../domain/electronObservation.js";
import {
AnalysisCapabilityUnavailableError,
AnalysisProtocolError,
PermissionRequiredError,
type AnalysisError,
} from "../domain/errors.js";
import { err, ok, type Result } from "../domain/result.js";
import { createElectronEvidence } from "./ElectronEvidence.js";
/** Authorize and list root-confined Electron page targets. */
export const listElectronTargets = async (
provider: ElectronObservationPort | undefined,
authority: PermissionAuthority | undefined,
input: ListElectronTargetsInput,
options: ExecutionOptions = {},
): Promise<Result<Evidence, AnalysisError>> => {
const ready = await prepare(
provider,
authority,
input,
"list_electron_targets",
);
if (!ready.ok) return ready;
const result = await ready.value.listTargets(input, options);
return result.ok
? ok(
createElectronEvidence(
"list_electron_targets",
input,
result.value,
ready.value.identity(),
),
)
: result;
};
/** Authorize and inspect one root-confined Electron page. */
export const inspectElectronPage = async (
provider: ElectronObservationPort | undefined,
authority: PermissionAuthority | undefined,
input: InspectElectronPageInput,
options: ExecutionOptions = {},
): Promise<Result<Evidence, AnalysisError>> => {
const ready = await prepare(
provider,
authority,
input,
"inspect_electron_page",
);
if (!ready.ok) return ready;
const result = await ready.value.inspectPage(input, options);
return result.ok
? ok(
createElectronEvidence(
"inspect_electron_page",
input,
result.value,
ready.value.identity(),
),
)
: result;
};
const prepare = async (
provider: ElectronObservationPort | undefined,
authority: PermissionAuthority | undefined,
input: ListElectronTargetsInput | InspectElectronPageInput,
operation: "list_electron_targets" | "inspect_electron_page",
): Promise<Result<ElectronObservationPort, AnalysisError>> => {
if (authority === undefined)
return err(
new AnalysisCapabilityUnavailableError(
"rea-cdp-electron",
operation,
"Electron observation permission policy is not configured",
),
);
const authorized = await authority.authorize(
{
capability: "electron_observe",
roots: input.allowed_file_roots,
executables: [],
environment_names: [],
origins: [input.cdp_endpoint],
network: "loopback",
mount: false,
operation_identity: `${operation}:${"target_id" in input ? input.target_id : input.cdp_endpoint}`,
},
"read",
);
if (!authorized.ok)
return err(
authorized.error instanceof PermissionRequiredError
? authorized.error
: new AnalysisProtocolError(authorized.error.message, {
cause: authorized.error,
}),
);
return provider === undefined
? err(
new AnalysisCapabilityUnavailableError(
"rea-cdp-electron",
operation,
"Electron observation provider is not configured",
),
)
: ok(provider);
};
+7 -10
View File
@@ -122,11 +122,9 @@ export class EvidenceLedger {
/** Export records in deterministic, semantically irrelevant ID order. */
export(): EvidenceBundle {
return structuredClone(
createEvidenceBundle(
[...this.#records.values()],
[...this.#unknownRevisions.values()],
),
return createEvidenceBundle(
[...this.#records.values()],
[...this.#unknownRevisions.values()],
);
}
@@ -234,7 +232,7 @@ export class EvidenceLedger {
const checked = this.#validateCandidate(pendingRecords, pendingUnknowns);
if (!checked.ok) return checked;
this.#commit(pendingRecords, pendingUnknowns, checked.value);
return ok(structuredClone(unknown));
return ok(unknown);
}
/** Apply one approved full-state update using optimistic revision matching. */
@@ -279,8 +277,7 @@ export class EvidenceLedger {
unknown.severity === filters.severity) &&
(filters.domain === undefined || unknown.domain === filters.domain),
)
.sort((left, right) => left.unknown_id.localeCompare(right.unknown_id))
.map((unknown) => structuredClone(unknown));
.sort((left, right) => left.unknown_id.localeCompare(right.unknown_id));
}
/** Return current resolution validity; imported invalid states are rejected. */
@@ -298,7 +295,7 @@ export class EvidenceLedger {
return ok({
valid: unknown.status === "resolved",
truthVerified: unknown.resolution?.disposition === "verified",
unknown: structuredClone(unknown),
unknown,
});
}
@@ -334,7 +331,7 @@ export class EvidenceLedger {
const checked = this.#validateCandidate(pendingRecords, pendingUnknowns);
if (!checked.ok) return checked;
this.#commit(pendingRecords, pendingUnknowns, checked.value);
return ok(structuredClone(unknown));
return ok(unknown);
}
#validateCandidate(
+29 -2
View File
@@ -11,7 +11,6 @@ import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
import writeFileAtomic from "write-file-atomic";
import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js";
import { isJsonWithinLimits } from "../domain/jsonLimits.js";
import { InvestigationWorkspaceError } from "../domain/errors.js";
import {
parseInvestigationWorkspace,
@@ -133,7 +132,7 @@ const readWorkspaceFile = async (
new InvestigationWorkspaceError("read", "invalid-json", { cause }),
);
}
if (!isJsonWithinLimits(decoded, policy))
if (!withinJsonLimits(decoded, policy))
return err(new InvestigationWorkspaceError("read", "too-large"));
try {
return ok(parseInvestigationWorkspace(decoded));
@@ -242,6 +241,34 @@ const validateNextRevision = (
: err(new InvestigationWorkspaceError("update", "revision-conflict"));
};
const withinJsonLimits = (
root: unknown,
policy: EvidenceFilePolicy,
): boolean => {
const pending: Array<{ readonly value: unknown; readonly depth: number }> = [
{ value: root, depth: 0 },
];
let nodes = 0;
while (pending.length > 0) {
const current = pending.pop();
if (current === undefined) break;
nodes += 1;
if (nodes > policy.maxNodes || current.depth > policy.maxDepth)
return false;
if (
typeof current.value === "string" &&
current.value.length > policy.maxStringLength
)
return false;
if (typeof current.value !== "object" || current.value === null) continue;
for (const [key, value] of Object.entries(current.value)) {
if (key.length > policy.maxStringLength) return false;
pending.push({ value, depth: current.depth + 1 });
}
}
return true;
};
const isFileNotFound = (cause: unknown): boolean =>
errorCode(cause) === "ENOENT";
-278
View File
@@ -1,278 +0,0 @@
import { realpath } from "node:fs/promises";
import { basename, dirname, join, resolve } from "node:path";
import type {
PermissionCeiling,
PermissionDecision,
PermissionGrant,
PermissionPolicy,
PermissionRequest,
PermissionScope,
} from "../domain/permissionPolicy.js";
import {
consumePermission,
clearSessionPermissions,
createPermissionPolicy,
evaluatePermission,
grantPermission,
reloadPermissionCeilings,
revokePermission,
} from "../domain/permissionPolicy.js";
import { PermissionRequiredError } from "../domain/errors.js";
import { err, ok, type Result } from "../domain/result.js";
/** Failure to establish a filesystem object's canonical authorization identity. */
export class PermissionPathError extends Error {
readonly _tag = "PermissionPathError" as const;
constructor(
readonly path: string,
readonly reason: "not_found" | "io",
options?: ErrorOptions,
) {
super(`Cannot canonicalize permission path: ${path}`, options);
}
}
/** Canonicalize administrator ceilings before they become authorization state. */
export const canonicalizePermissionCeilings = async (
ceilings: readonly PermissionCeiling[],
): Promise<Result<readonly PermissionCeiling[], PermissionPathError>> => {
const normalized: PermissionCeiling[] = [];
for (const ceiling of ceilings) {
const canonical = await canonicalizeScope(ceiling, "read");
if (!canonical.ok) return canonical;
normalized.push(canonical.value);
}
return ok(normalized);
};
/** Canonicalize one exact operation request before subset evaluation. */
export const canonicalizePermissionRequest = async (
request: PermissionRequest,
access: "read" | "write",
): Promise<Result<PermissionRequest, PermissionPathError>> => {
const canonical = await canonicalizeScope(request, access);
return canonical.ok
? ok({ ...canonical.value, operation_identity: request.operation_identity })
: canonical;
};
const canonicalizeScope = async (
scope: PermissionScope,
access: "read" | "write",
): Promise<Result<PermissionScope, PermissionPathError>> => {
const roots = await canonicalizePaths(scope.roots, access);
if (!roots.ok) return roots;
const executables = await canonicalizePaths(scope.executables, "read");
if (!executables.ok) return executables;
return ok({ ...scope, roots: roots.value, executables: executables.value });
};
const canonicalizePaths = async (
paths: readonly string[],
access: "read" | "write",
): Promise<Result<readonly string[], PermissionPathError>> => {
const canonical: string[] = [];
for (const path of paths) {
const result = await canonicalizePath(path, access);
if (!result.ok) return result;
canonical.push(result.value);
}
return ok(canonical);
};
const canonicalizePath = async (
path: string,
access: "read" | "write",
): Promise<Result<string, PermissionPathError>> => {
const requested = resolve(path);
try {
return ok(await realpath(requested));
} catch (cause: unknown) {
if (access !== "write" || !isNotFound(cause))
return err(new PermissionPathError(path, errorReason(cause), { cause }));
}
try {
const parent = await realpath(dirname(requested));
return ok(join(parent, basename(requested)));
} catch (cause: unknown) {
return err(new PermissionPathError(path, errorReason(cause), { cause }));
}
};
const isNotFound = (error: unknown): boolean =>
error instanceof Error && "code" in error && error.code === "ENOENT";
const errorReason = (error: unknown): PermissionPathError["reason"] =>
isNotFound(error) ? "not_found" : "io";
/** Stateful owner for permission evaluation, consumption, reload, and revoke. */
export class PermissionAuthority {
private policy: PermissionPolicy;
constructor(policy: PermissionPolicy) {
this.policy = policy;
}
/** Canonicalize and authorize one exact operation before its side effect. */
async authorize(
request: PermissionRequest,
access: "read" | "write",
options: {
readonly elicitationSupported?: boolean;
readonly restartRequired?: boolean;
} = {},
): Promise<
Result<PermissionDecision, PermissionPathError | PermissionRequiredError>
> {
const evaluated = await this.explain(request, access, options);
if (!evaluated.ok) return evaluated;
const decision = evaluated.value;
this.policy = consumePermission(this.policy, decision);
return ok(decision);
}
/** Dry-run the exact shared evaluation without consuming a once grant. */
async explain(
request: PermissionRequest,
access: "read" | "write",
options: {
readonly elicitationSupported?: boolean;
readonly restartRequired?: boolean;
} = {},
): Promise<
Result<
PermissionDecision & { readonly allowed: true },
PermissionPathError | PermissionRequiredError
>
> {
const canonical = await canonicalizePermissionRequest(request, access);
if (!canonical.ok) return canonical;
const decision = evaluatePermission(this.policy, canonical.value);
if (!decision.allowed)
return err(
new PermissionRequiredError(
canonical.value,
decision.missing,
ceilingFor(this.policy, canonical.value.capability),
options.elicitationSupported ?? false,
options.restartRequired ?? false,
),
);
return ok(decision);
}
/** Add authority beneath the administrator ceiling. */
grant(grant: PermissionGrant): ReturnType<typeof grantPermission> {
const result = grantPermission(this.policy, grant);
if (result.ok) this.policy = result.value;
return result;
}
/** Revoke authority immediately for subsequent operations. */
revoke(grantId: string): void {
this.policy = revokePermission(this.policy, grantId);
}
/** Drop once/session authority when its owning connection ends. */
clearSessionGrants(): void {
this.policy = clearSessionPermissions(this.policy);
}
/** Atomically replace optional persisted project grants. */
async replaceProjectGrants(
grants: readonly PermissionGrant[],
): Promise<Result<null, PermissionPathError>> {
let candidate: PermissionPolicy = {
...this.policy,
grants: this.policy.grants.filter(
({ lifetime }) => lifetime !== "project",
),
};
for (const grant of grants) {
const canonical = await canonicalizeGrant(grant);
if (!canonical.ok) return canonical;
const added = grantPermission(candidate, canonical.value);
if (!added.ok)
return err(
new PermissionPathError(grant.grant_id, "io", {
cause: added.error,
}),
);
candidate = added.value;
}
this.policy = candidate;
return ok(null);
}
/** Replace grants derived from the currently loaded administrator ceiling. */
async replaceAdministratorGrants(
grants: readonly PermissionGrant[],
): Promise<Result<null, PermissionPathError>> {
let candidate: PermissionPolicy = {
...this.policy,
grants: this.policy.grants.filter(
({ lifetime }) => lifetime !== "administrator",
),
};
for (const grant of grants) {
const canonical = await canonicalizeGrant(grant);
if (!canonical.ok) return canonical;
const added = grantPermission(candidate, canonical.value);
if (!added.ok)
return err(
new PermissionPathError(grant.grant_id, "io", {
cause: added.error,
}),
);
candidate = added.value;
}
this.policy = candidate;
return ok(null);
}
/** Replace ceilings without restarting the MCP process. */
async reload(
ceilings: readonly PermissionCeiling[],
): Promise<Result<null, PermissionPathError>> {
const canonical = await canonicalizePermissionCeilings(ceilings);
if (!canonical.ok) return canonical;
this.policy = reloadPermissionCeilings(this.policy, canonical.value);
return ok(null);
}
}
/** Create one canonical policy and optional grants at the composition root. */
export const createPermissionAuthority = async (
ceilings: readonly PermissionCeiling[],
grants: readonly PermissionGrant[] = [],
): Promise<Result<PermissionAuthority, PermissionPathError>> => {
const canonical = await canonicalizePermissionCeilings(ceilings);
if (!canonical.ok) return canonical;
let policy = createPermissionPolicy(canonical.value);
for (const grant of grants) {
const canonicalGrant = await canonicalizeGrant(grant);
if (!canonicalGrant.ok) return canonicalGrant;
const added = grantPermission(policy, canonicalGrant.value);
if (!added.ok)
return err(
new PermissionPathError(grant.grant_id, "io", { cause: added.error }),
);
policy = added.value;
}
return ok(new PermissionAuthority(policy));
};
const canonicalizeGrant = async (
grant: PermissionGrant,
): Promise<Result<PermissionGrant, PermissionPathError>> => {
const scope = await canonicalizeScope(grant, "read");
return scope.ok ? ok({ ...grant, ...scope.value }) : scope;
};
const ceilingFor = (
policy: PermissionPolicy,
capability: PermissionRequest["capability"],
): PermissionScope | null =>
policy.ceilings.find((ceiling) => ceiling.capability === capability) ?? null;
@@ -1,52 +0,0 @@
import type { AppConfig } from "../config.js";
import { ConfigurationError } from "../domain/errors.js";
import { err, ok, type Result } from "../domain/result.js";
import {
createPermissionAuthority,
type PermissionAuthority,
} from "./PermissionAuthority.js";
import { readProjectPermissionStore } from "./ProjectPermissionStore.js";
/** Load the same administrator and optional project policy for CLI and MCP. */
export const loadConfiguredPermissionAuthority = async (
config: AppConfig,
): Promise<Result<PermissionAuthority, ConfigurationError>> => {
const authority = await createPermissionAuthority(
config.permissionCeilings,
config.administratorPermissionGrants,
);
if (!authority.ok)
return err(
new ConfigurationError("Permission policy could not be loaded", {
cause: authority.error,
}),
);
if (
config.permissionProjectRoot === undefined ||
config.permissionProjectStore === undefined
)
return ok(authority.value);
const project = await readProjectPermissionStore(
config.permissionProjectStore,
config.permissionProjectRoot,
);
if (!project.ok)
return err(
new ConfigurationError("Project permission policy could not be loaded", {
cause: project.error,
}),
);
const replaced = await authority.value.replaceProjectGrants(
project.value?.grants ?? [],
);
return replaced.ok
? ok(authority.value)
: err(
new ConfigurationError(
"Project permission policy could not be applied",
{
cause: replaced.error,
},
),
);
};
+5 -4
View File
@@ -8,7 +8,6 @@ import {
ProcessCaptureError,
processCaptureCancelled,
} from "./ProcessCaptureError.js";
import { canonicalizeConfiguredRoots } from "./ConfiguredRoots.js";
const isWithin = (candidate: string, root: string): boolean =>
candidate === root ||
@@ -19,15 +18,17 @@ export const assertRealPathAuthority = async (
policy: ProcessExecutionPolicy,
): Promise<void> => {
const executable = await realpath(scenario.executable);
const executableRoots = await canonicalizeConfiguredRoots(
policy.executableRoots,
const executableRoots = await Promise.all(
policy.executableRoots.map((root) => realpath(root)),
);
if (!executableRoots.some((root) => isWithin(executable, root)))
throw new ProcessCaptureError(
"resolved executable is outside approved roots",
);
const workingDirectory = await realpath(scenario.working_directory);
const workingRoots = await canonicalizeConfiguredRoots(policy.workingRoots);
const workingRoots = await Promise.all(
policy.workingRoots.map((root) => realpath(root)),
);
if (!workingRoots.some((root) => isWithin(workingDirectory, root)))
throw new ProcessCaptureError(
"resolved working directory is outside approved roots",
-19
View File
@@ -3,12 +3,6 @@ import { AnalysisError } from "../domain/errors.js";
interface ProcessCaptureErrorOptions extends ErrorOptions {
readonly userMessage?: string;
readonly userCategory?: "permission_required" | "cancelled";
readonly reason?:
| "capture_failed"
| "cleanup_incomplete"
| "permission_required"
| "cancelled";
readonly cleanupResources?: readonly string[];
}
/** Typed application failure produced by controlled process capture. */
@@ -20,23 +14,11 @@ export class ProcessCaptureError extends AnalysisError {
| "permission_required"
| "cancelled"
| undefined;
readonly reason: NonNullable<ProcessCaptureErrorOptions["reason"]>;
override readonly cleanupIncomplete: boolean;
override readonly cleanupResources: readonly string[];
constructor(message: string, options?: ProcessCaptureErrorOptions) {
super(message, options);
this.userMessage = options?.userMessage;
this.userCategory = options?.userCategory;
this.reason =
options?.reason ??
(options?.userCategory === "permission_required"
? "permission_required"
: options?.userCategory === "cancelled"
? "cancelled"
: "capture_failed");
this.cleanupIncomplete = this.reason === "cleanup_incomplete";
this.cleanupResources = options?.cleanupResources ?? [];
}
}
@@ -44,6 +26,5 @@ export class ProcessCaptureError extends AnalysisError {
export const processCaptureCancelled = (): ProcessCaptureError =>
new ProcessCaptureError("process capture was cancelled", {
userCategory: "cancelled",
reason: "cancelled",
userMessage: "Process capture was cancelled. Start it again when ready.",
});
+9 -28
View File
@@ -1,6 +1,6 @@
import { createHash, randomUUID } from "node:crypto";
import { createReadStream } from "node:fs";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { IPty } from "node-pty";
@@ -338,12 +338,14 @@ export const captureTerminalFrames = (options: {
readonly started: number;
readonly temporaryRoot: string;
readonly onOutput: () => void;
readonly onFirstOutput: () => void;
readonly renderer: TerminalRenderer;
readonly checkpoints: ProcessCheckpoints;
}): (() => boolean) => {
let outputBytes = 0;
let truncated = false;
options.terminal.onData((data) => {
options.onFirstOutput();
options.onOutput();
const bytes = Buffer.byteLength(data);
if (
@@ -387,9 +389,7 @@ export const resolveProcessResult = (
cause: executionFailure,
});
if (cleanupFailure !== undefined)
throw new ProcessCaptureError(cleanupFailure, {
reason: "cleanup_incomplete",
});
throw new ProcessCaptureError(cleanupFailure);
if (capture === undefined)
throw new ProcessCaptureError("process capture produced no result");
return capture;
@@ -399,7 +399,6 @@ export const prepareProcessCapture = async (
scenario: ProcessScenario,
policy: ProcessExecutionPolicy,
signal: AbortSignal | undefined,
host: ProcessPreparationHost = systemProcessPreparationHost,
): Promise<{
readonly temporaryRoot: string;
readonly runId: string;
@@ -415,29 +414,11 @@ export const prepareProcessCapture = async (
await assertRealPathAuthority(scenario, policy);
assertNotCancelled(signal);
const before = await snapshotRoots(scenario, signal);
const temporaryRoot = await host.createTemporaryRoot();
try {
const runId = randomUUID();
const home = join(temporaryRoot, "home");
await host.createHome(home);
return { temporaryRoot, runId, home, before };
} catch (cause: unknown) {
await host.cleanup(temporaryRoot);
throw cause;
}
};
/** Filesystem seam for allocating and cleaning a capture's private home. */
export interface ProcessPreparationHost {
createTemporaryRoot(): Promise<string>;
createHome(path: string): Promise<void>;
cleanup(path: string): Promise<void>;
}
const systemProcessPreparationHost: ProcessPreparationHost = {
createTemporaryRoot: () => mkdtemp(join(tmpdir(), "rea-process-")),
createHome: (path) => mkdir(path),
cleanup: (path) => rm(path, { recursive: true, force: true }),
const temporaryRoot = await mkdtemp(join(tmpdir(), "rea-process-"));
const runId = randomUUID();
const home = join(temporaryRoot, "home");
await import("node:fs/promises").then(({ mkdir }) => mkdir(home));
return { temporaryRoot, runId, home, before };
};
const processPolicyMessage = (
+1 -1
View File
@@ -126,7 +126,7 @@ export class ProcessCheckpoints {
effects: classifyFilesystemEffects(previous, after.files),
truncated: after.truncated,
});
return structuredClone(this.#captures);
return this.#captures;
}
/** Cancel scheduled captures and await any snapshot already in progress. */
+19 -52
View File
@@ -1,12 +1,7 @@
import { readFile } from "node:fs/promises";
import { parseConfig } from "../config.js";
import {
AnalysisError,
AnalysisProtocolError,
PermissionRequiredError,
projectAnalysisError,
} from "../domain/errors.js";
import { AnalysisError, projectAnalysisError } from "../domain/errors.js";
import { createEvidence, parseEvidence } from "../domain/evidence.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import {
@@ -20,7 +15,6 @@ import {
PROCESS_PROVIDER,
createProcessCaptureEvidence,
} from "./ProcessEvidence.js";
import { loadConfiguredPermissionAuthority } from "./PermissionConfiguration.js";
const MAX_INPUT_BYTES = 64 * 1024 * 1024;
@@ -31,6 +25,19 @@ export interface ProcessCliErrorOutput {
readonly message: string;
}
/** Distinguish a safe process-command failure from successful Evidence. */
export const isProcessCliErrorOutput = (
value: unknown,
): value is ProcessCliErrorOutput =>
typeof value === "object" &&
value !== null &&
"error" in value &&
value.error === "Process command failed" &&
"category" in value &&
typeof value.category === "string" &&
"message" in value &&
typeof value.message === "string";
/** Capture one JSON scenario through the same policy and evidence contract as MCP. */
export const captureProcessScenarioFile = async (path: string) => {
try {
@@ -46,31 +53,6 @@ export const captureProcessScenarioFile = async (path: string) => {
}
const config = parseConfig(process.env);
if (!config.ok) return cliAnalysisError(config.error);
const authority = await loadConfiguredPermissionAuthority(config.value);
if (!authority.ok) return cliAnalysisError(authority.error);
const authorized = await authority.value.authorize(
{
capability: "process_capture",
roots: [scenario.working_directory, ...scenario.filesystem_roots],
executables: [scenario.executable],
environment_names: [
...Object.keys(scenario.environment),
...scenario.inherit_environment,
],
network: scenario.network_access === "host" ? "external" : "none",
mount: false,
operation_identity: `capture_process_scenario:${scenario.executable}`,
},
"read",
);
if (!authorized.ok)
return cliAnalysisError(
authorized.error instanceof PermissionRequiredError
? authorized.error
: new AnalysisProtocolError(authorized.error.message, {
cause: authorized.error,
}),
);
const captured = await captureProcessScenario(
scenario,
config.value.processExecutionPolicy,
@@ -112,12 +94,7 @@ export const compareProcessEvidenceFiles = async (
};
const parseCaptureEvidence = (input: unknown) => {
let evidence;
try {
evidence = parseEvidence(input);
} catch {
throw invalidCaptureEvidence();
}
const evidence = parseEvidence(input);
if (
evidence.operation !== "capture_process_scenario" ||
evidence.predicate_type !== "rea.process-capture/v4" ||
@@ -134,22 +111,12 @@ const parseCaptureEvidence = (input: unknown) => {
"Capture evidence is not from the current process-capture workflow. Create new capture evidence, then try again.",
);
}
try {
return {
id: evidence.evidence_id,
capture: parseProcessCapture(evidence.normalized_result),
};
} catch {
throw invalidCaptureEvidence();
}
return {
id: evidence.evidence_id,
capture: parseProcessCapture(evidence.normalized_result),
};
};
const invalidCaptureEvidence = (): ProcessCliFailure =>
new ProcessCliFailure(
"invalid_input",
"Capture evidence is malformed. Create new capture evidence, then try again.",
);
const readJson = async (path: string): Promise<unknown> => {
let bytes;
try {
+30 -18
View File
@@ -130,6 +130,20 @@ const scheduleScenarioEvents = (options: ScenarioEventOptions): void => {
}
};
const startScenarioEvents = (options: ScenarioEventOptions): (() => void) => {
let eventsStarted = false;
return () => {
if (eventsStarted) return;
eventsStarted = true;
scheduleScenarioEvents(options);
};
};
const eventsRequireReadiness = (scenario: ProcessScenario): boolean =>
scenario.events.some(
(event) => event.type === "input" || event.type === "resize",
);
interface StartedCaptureRuntime {
readonly replay: LoopbackReplay;
readonly renderer: TerminalRenderer;
@@ -160,10 +174,7 @@ const cleanupFailedStartup = async (options: {
sampledProcessGroupIds: [],
});
if (cleanupFailure !== undefined)
throw new ProcessCaptureError(cleanupFailure, {
cause: options.cause,
reason: "cleanup_incomplete",
});
throw new ProcessCaptureError(cleanupFailure, { cause: options.cause });
throw options.cause;
};
@@ -229,27 +240,28 @@ const startCaptureRuntime = async (options: {
rows: scenario.terminal.rows,
name: "xterm-256color",
});
const framesTruncated = captureTerminalFrames({
...options,
terminal,
started,
onOutput: () => (lastOutput = Date.now()),
renderer,
checkpoints,
});
scheduleScenarioEvents({
const startEvents = startScenarioEvents({
...options,
getTerminal: () => terminal,
renderer,
started,
});
const stopSampler = startProcessSampler({
rootPid: terminal.pid,
runId: options.runId,
if (!eventsRequireReadiness(scenario)) startEvents();
const framesTruncated = captureTerminalFrames({
...options,
terminal,
started,
limit: scenario.limits.processes,
samples: options.samples,
onOutput: () => (lastOutput = Date.now()),
onFirstOutput: startEvents,
renderer,
checkpoints,
});
const stopSampler = startProcessSampler(
terminal.pid,
started,
scenario.limits.processes,
options.samples,
);
return {
replay,
renderer,
+7 -28
View File
@@ -31,7 +31,7 @@ export const normalizeProcessText = (
);
}
if (scenario.normalization.pids)
normalized = normalizePidTokens(normalized, [pid]);
normalized = normalized.replaceAll(String(pid), "<pid>");
if (scenario.normalization.ports)
normalized = normalized.replaceAll(/(?<=[:=])\d{2,5}\b/g, "<port>");
for (const pattern of scenario.normalization.patterns)
@@ -57,17 +57,6 @@ export const normalizeProcessSamples = (
for (const identifier of identifiers)
if (identifier > 0 && !mapping.has(identifier))
mapping.set(identifier, mapping.size + 1);
const normalizeCommand = (command: string): string => {
const normalized = normalizeProcessText(
command,
scenario,
"<no-temporary-root>",
rootPid,
);
return scenario.normalization.pids
? normalizePidTokens(normalized, identifiers)
: normalized;
};
return samples.map((sample) => ({
at_ms:
Math.floor(sample.at_ms / scenario.normalization.time_bucket_ms) *
@@ -80,25 +69,15 @@ export const normalizeProcessSamples = (
: (mapping.get(sample.process_group_id) ?? 0),
session_id:
sample.session_id === null ? null : (mapping.get(sample.session_id) ?? 0),
command: normalizeCommand(sample.command),
command: normalizeProcessText(
sample.command,
scenario,
"<no-temporary-root>",
rootPid,
),
}));
};
const normalizePidTokens = (
value: string,
identifiers: readonly number[],
): string => {
const tokens = [...new Set(identifiers)]
.filter((identifier) => Number.isSafeInteger(identifier) && identifier > 0)
.sort((left, right) => right - left)
.map(String);
if (tokens.length === 0) return value;
return value.replace(
new RegExp(`(?<!\\d)(?:${tokens.join("|")})(?!\\d)`, "gu"),
"<pid>",
);
};
export const redactProtocolEvents = (
events: readonly ProcessCapture["protocol_events"][number][],
scenario: ProcessScenario,
+24 -58
View File
@@ -19,7 +19,6 @@ interface ProcessGroupMember {
readonly pid: number;
readonly parentPid: number;
readonly processGroupId: number;
readonly state: string;
readonly command: string;
}
@@ -31,19 +30,9 @@ export interface ProcessOwnershipHost {
}
/** Cleanup succeeds only when the group is absent or every member is owned. */
export interface ProcessOwnershipValidationFailure {
readonly pid: number;
readonly reason: "environment-unreadable" | "run-token-mismatch";
}
/** Cleanup outcome with per-member diagnostics when ownership is uncertain. */
export type ProcessCleanupResult =
| { readonly cleaned: true; readonly signaled: boolean }
| {
readonly cleaned: false;
readonly reason: string;
readonly failures?: readonly ProcessOwnershipValidationFailure[];
};
| { readonly cleaned: false; readonly reason: string };
/** Token-verified liveness result used by post-root-exit settlement. */
export type ProcessGroupObservation =
@@ -51,14 +40,11 @@ export type ProcessGroupObservation =
| { readonly state: "alive" }
| { readonly state: "unverifiable"; readonly reason: string };
/** Parse the NUL-delimited Linux process environment without nameless keys. */
export const parseProcessEnvironment = (
value: string,
): Readonly<Record<string, string>> =>
const parseEnvironment = (value: string): Readonly<Record<string, string>> =>
Object.fromEntries(
value
.split("\0")
.filter((entry) => entry.indexOf("=") > 0)
.filter((entry) => entry.includes("="))
.map((entry) => {
const separator = entry.indexOf("=");
return [entry.slice(0, separator), entry.slice(separator + 1)];
@@ -70,26 +56,23 @@ const systemHost: ProcessOwnershipHost = {
if (process.platform === "win32") return [];
const { stdout } = await execFileAsync("ps", [
"-axo",
"pid=,ppid=,pgid=,stat=,command=",
"pid=,ppid=,pgid=,command=",
]);
return stdout
.split("\n")
.map((line) => /\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.*)/u.exec(line))
.map((line) => /\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)/u.exec(line))
.filter((match): match is RegExpExecArray => match !== null)
.map((match) => ({
pid: Number(match[1]),
parentPid: Number(match[2]),
processGroupId: Number(match[3]),
state: match[4] ?? "",
command: match[5] ?? "",
command: match[4] ?? "",
}))
.filter((member) => member.processGroupId === processGroupId);
},
async environment(pid) {
if (process.platform === "linux") {
return parseProcessEnvironment(
await readFile(`/proc/${pid}/environ`, "utf8"),
);
return parseEnvironment(await readFile(`/proc/${pid}/environ`, "utf8"));
}
const { stdout } = await execFileAsync("ps", ["eww", "-p", String(pid)]);
const environment: Record<string, string> = {};
@@ -104,13 +87,6 @@ const systemHost: ProcessOwnershipHost = {
},
};
/** Read the capture run token currently exposed by one live process. */
export const readProcessRunId = async (
pid: number,
host: ProcessOwnershipHost = systemHost,
): Promise<string | undefined> =>
(await host.environment(pid)).REA_PROCESS_RUN_ID;
/**
* Verify run-token ownership before signaling a POSIX process group.
*
@@ -128,11 +104,8 @@ export const cleanupOwnedProcessGroup = async (
} catch {
return { cleaned: false, reason: "process group could not be inspected" };
}
const liveMembers = liveProcessGroupMembers(members);
if (liveMembers.length === 0) return { cleaned: true, signaled: false };
const leader = liveMembers.find(
(member) => member.pid === ownership.leaderPid,
);
if (members.length === 0) return { cleaned: true, signaled: false };
const leader = members.find((member) => member.pid === ownership.leaderPid);
if (leader !== undefined) {
if (
ownership.expectedParentPid !== undefined &&
@@ -151,24 +124,23 @@ export const cleanupOwnedProcessGroup = async (
reason: "owned launcher command identity did not match",
};
}
const failures: ProcessOwnershipValidationFailure[] = [];
for (const member of liveMembers) {
for (const member of members) {
let environment: Readonly<Record<string, string>>;
try {
const environment = await host.environment(member.pid);
if (environment.REA_PROCESS_RUN_ID !== ownership.runId)
failures.push({ pid: member.pid, reason: "run-token-mismatch" });
environment = await host.environment(member.pid);
} catch {
failures.push({ pid: member.pid, reason: "environment-unreadable" });
return {
cleaned: false,
reason: "process ownership could not be revalidated",
};
}
if (environment.REA_PROCESS_RUN_ID !== ownership.runId) {
return {
cleaned: false,
reason: "process group contains an unowned or PID-reused process",
};
}
}
if (failures.length > 0)
return {
cleaned: false,
reason: failures.some(({ reason }) => reason === "run-token-mismatch")
? "process group contains an unowned or PID-reused process"
: "process ownership could not be revalidated",
failures,
};
try {
host.signalGroup(ownership.processGroupId, "SIGKILL");
} catch (cause: unknown) {
@@ -194,9 +166,8 @@ export const observeOwnedProcessGroup = async (
reason: "process group could not be inspected",
};
}
const liveMembers = liveProcessGroupMembers(members);
if (liveMembers.length === 0) return { state: "empty" };
for (const member of liveMembers) {
if (members.length === 0) return { state: "empty" };
for (const member of members) {
try {
if (
(await host.environment(member.pid)).REA_PROCESS_RUN_ID !==
@@ -216,11 +187,6 @@ export const observeOwnedProcessGroup = async (
return { state: "alive" };
};
const liveProcessGroupMembers = (
members: readonly ProcessGroupMember[],
): readonly ProcessGroupMember[] =>
members.filter(({ state }) => !state.startsWith("Z"));
const commandMatches = (actual: string, expected: string): boolean => {
const normalizedActual = actual.trim();
const normalizedExpected = expected.trim();
+29 -164
View File
@@ -1,8 +1,7 @@
import { execFile } from "node:child_process";
import { readFile, readdir } from "node:fs/promises";
import { readFile } from "node:fs/promises";
import { promisify } from "node:util";
import type { ProcessSample } from "../domain/processCapture.js";
import { readProcessRunId } from "./ProcessOwnership.js";
const execFileAsync = promisify(execFile);
const PROC_READ_CONCURRENCY = 64;
@@ -28,29 +27,6 @@ interface SampleProcessContext {
readonly signal: AbortSignal;
}
type ProcessIdentitySnapshot = Pick<
ProcessRow,
"pid" | "parent_pid" | "process_group_id" | "session_id" | "startTime"
>;
/** Prove that forkpty completed session setup and installed the run token. */
export const isInitializedPtyRoot = (options: {
readonly rootPid: number;
readonly expectedRunId: string;
readonly before: ProcessIdentitySnapshot;
readonly observedRunId: string | undefined;
readonly after: ProcessIdentitySnapshot;
}): boolean =>
options.before.pid === options.rootPid &&
options.before.process_group_id === options.rootPid &&
options.before.session_id === options.rootPid &&
options.after.pid === options.rootPid &&
options.after.parent_pid === options.before.parent_pid &&
options.after.process_group_id === options.rootPid &&
options.after.session_id === options.rootPid &&
options.after.startTime === options.before.startTime &&
options.observedRunId === options.expectedRunId;
const isExpectedProcReadFailure = (cause: unknown): boolean =>
cause instanceof Error &&
"code" in cause &&
@@ -128,79 +104,25 @@ const readProcessCommand = async (
}
};
/** Procfs reads used to enumerate children created by every process thread. */
export interface LinuxChildrenHost {
taskIds(
pid: number,
signal: AbortSignal,
): Promise<readonly number[] | undefined>;
children(
pid: number,
taskId: number,
signal: AbortSignal,
): Promise<string | undefined>;
}
const systemLinuxChildrenHost: LinuxChildrenHost = {
async taskIds(pid, signal) {
try {
signal.throwIfAborted();
const entries = await readdir(`/proc/${String(pid)}/task`);
signal.throwIfAborted();
return entries
.filter((entry) => /^\d+$/.test(entry))
.map(Number)
.filter((taskId) => Number.isSafeInteger(taskId) && taskId > 0)
.sort((left, right) => left - right);
} catch (cause: unknown) {
return handleProcReadFailure(cause);
}
},
async children(pid, taskId, signal) {
try {
return await readFile(
`/proc/${String(pid)}/task/${String(taskId)}/children`,
{ encoding: "utf8", signal },
);
} catch (cause: unknown) {
return handleProcReadFailure(cause);
}
},
};
/** Read the deduplicated children created by every thread in a Linux process. */
export const readLinuxChildren = async (
const readLinuxChildren = async (
pid: number,
signal: AbortSignal,
host: LinuxChildrenHost = systemLinuxChildrenHost,
): Promise<readonly number[] | undefined> => {
const taskIds = await host.taskIds(pid, signal);
if (taskIds === undefined) return undefined;
const children = new Set<number>();
let successfulReads = 0;
for (
let offset = 0;
offset < taskIds.length;
offset += PROC_READ_CONCURRENCY
) {
const texts = await Promise.all(
taskIds
.slice(offset, offset + PROC_READ_CONCURRENCY)
.map((taskId) => host.children(pid, taskId, signal)),
);
for (const text of texts) {
if (text === undefined) continue;
successfulReads += 1;
for (const value of text.trim().split(/\s+/)) {
if (!/^\d+$/.test(value)) continue;
const child = Number(value);
if (Number.isSafeInteger(child) && child > 0) children.add(child);
}
}
let text: string;
try {
text = await readFile(`/proc/${String(pid)}/task/${String(pid)}/children`, {
encoding: "utf8",
signal,
});
} catch (cause: unknown) {
return handleProcReadFailure(cause);
}
return successfulReads === 0
? undefined
: [...children].sort((left, right) => left - right);
return text
.trim()
.split(/\s+/)
.filter((value) => /^\d+$/.test(value))
.map(Number)
.filter((child) => Number.isSafeInteger(child) && child > 0);
};
const inspectProcess = async (
@@ -329,47 +251,6 @@ const readPsRows = async (
.sort((left, right) => left.pid - right.pid);
};
const readRootIdentity = async (
rootPid: number,
signal: AbortSignal,
): Promise<ProcessRow | undefined> =>
process.platform === "linux"
? readProcessStat(rootPid, signal)
: (await readPsRows(signal)).find(({ pid }) => pid === rootPid);
const inspectInitializedPtyRoot = async (
rootPid: number,
runId: string,
signal: AbortSignal,
): Promise<{ readonly startTime: string | undefined } | undefined> => {
const before = await readRootIdentity(rootPid, signal);
if (
before === undefined ||
before.process_group_id !== rootPid ||
before.session_id !== rootPid
)
return undefined;
let observedRunId: string | undefined;
try {
observedRunId = await readProcessRunId(rootPid);
} catch {
return undefined;
}
const after = await readRootIdentity(rootPid, signal);
if (
after === undefined ||
!isInitializedPtyRoot({
rootPid,
expectedRunId: runId,
before,
observedRunId,
after,
})
)
return undefined;
return { startTime: after.startTime };
};
const samplePs = async (
context: SampleProcessContext,
): Promise<readonly ProcessRow[]> => {
@@ -440,47 +321,31 @@ const sampleProcesses = async (
};
/** Start bounded process-tree sampling and expose an awaited stop. */
export const startProcessSampler = (options: {
readonly rootPid: number;
readonly runId: string;
readonly started: number;
readonly limit: number;
readonly samples: ProcessSample[];
}): (() => Promise<{ readonly partial: boolean }>) => {
const { rootPid, runId, started, limit, samples } = options;
export const startProcessSampler = (
rootPid: number,
started: number,
limit: number,
samples: ProcessSample[],
): (() => Promise<{ readonly partial: boolean }>) => {
const identities = new Map<number, string>();
const lastObservations = new Map<number, string>();
let pending: Promise<void> | undefined;
let stopped = false;
let abortCurrent: (() => void) | undefined;
let partial = false;
let rootInitialized = false;
const sample = (): void => {
if (stopped || pending !== undefined) return;
const controller = new AbortController();
abortCurrent = () => controller.abort();
pending = (async () => {
if (!rootInitialized) {
const identity = await inspectInitializedPtyRoot(
rootPid,
runId,
controller.signal,
);
if (identity === undefined) return [];
if (identity.startTime !== undefined)
identities.set(rootPid, identity.startTime);
rootInitialized = true;
}
return sampleProcesses({
rootPid,
elapsedMs: Date.now() - started,
limit: limit + 1,
identities,
signal: controller.signal,
});
})()
pending = sampleProcesses({
rootPid,
elapsedMs: Date.now() - started,
limit: limit + 1,
identities,
signal: controller.signal,
})
.then((values) => {
for (const value of values) {
const observation = JSON.stringify({
-55
View File
@@ -1,55 +0,0 @@
/** Provider-neutral progress observation for one ordinary request. */
export interface ProgressUpdate {
readonly phase: string;
readonly completed: number;
readonly total: number | null;
readonly message: string;
readonly terminal?: boolean;
}
/** Progress boundary shared by CLI, MCP, application workflows, and providers. */
export interface ProgressReporter {
report(update: ProgressUpdate): Promise<void>;
}
/** Create a monotonic, rate-bounded reporter around a surface-specific sink. */
export const createProgressReporter = (
sink: (
update: ProgressUpdate & { readonly sequence: number },
) => Promise<void>,
options: {
readonly minimumIntervalMs?: number;
readonly now?: () => number;
} = {},
): ProgressReporter => {
const minimumIntervalMs = options.minimumIntervalMs ?? 100;
const now = options.now ?? Date.now;
let lastCompleted = -1;
let lastSentAt = Number.NEGATIVE_INFINITY;
let sequence = 0;
return {
async report(update) {
if (!Number.isFinite(update.completed) || update.completed < 0)
throw new TypeError("Progress completed must be a nonnegative number");
if (update.total !== null && update.total < update.completed)
throw new TypeError("Progress total cannot be less than completed");
if (update.completed < lastCompleted)
throw new TypeError("Progress cannot move backwards");
lastCompleted = update.completed;
const observedAt = now();
if (
update.terminal !== true &&
observedAt - lastSentAt < minimumIntervalMs
)
return;
lastSentAt = observedAt;
sequence += 1;
await sink({ ...update, sequence });
},
};
};
/** Surface that deliberately ignores progress while preserving one workflow API. */
export const silentProgressReporter: ProgressReporter = {
report: () => Promise.resolve(),
};
-277
View File
@@ -1,277 +0,0 @@
import { createHash } from "node:crypto";
import {
lstat,
mkdir,
open,
readFile,
realpath,
stat,
unlink,
type FileHandle,
} from "node:fs/promises";
import { dirname } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import writeFileAtomic from "write-file-atomic";
import { z } from "zod";
import type { PermissionGrant } from "../domain/permissionPolicy.js";
import { err, ok, type Result } from "../domain/result.js";
const grantSchema = z.object({
grant_id: z.string().min(1),
capability: z.enum([
"process_capture",
"browser_observe",
"electron_observe",
"evidence_read",
"evidence_write",
"investigation_input",
"investigation_workspace_read",
"investigation_workspace_write",
"snapshot_read",
"snapshot_write",
"artifact_extract",
"native_mount",
"reference_read",
]),
roots: z.array(z.string()),
executables: z.array(z.string()),
environment_names: z.array(z.string()),
origins: z.array(z.string()).optional(),
network: z.enum(["none", "loopback", "external"]),
mount: z.boolean(),
lifetime: z.literal("project"),
operation_identity: z.string().nullable(),
expires_at: z.iso.datetime().nullable(),
});
const storeSchema = z.object({
schema_version: z.literal(1),
project_id: z.string().regex(/^project_[a-f0-9]{64}$/u),
project_root: z.string(),
grants: z.array(grantSchema).max(1_000),
});
export type ProjectPermissionStore = z.infer<typeof storeSchema>;
/** Owner-only project policy persistence failure. */
export class ProjectPermissionStoreError extends Error {
readonly _tag = "ProjectPermissionStoreError" as const;
constructor(
readonly reason:
| "project_not_found"
| "not_owner_only"
| "invalid"
| "locked"
| "io",
options?: ErrorOptions,
) {
super(`Project permission store failed: ${reason}`, options);
}
}
/** Derive relocation-explicit project identity from its canonical root. */
const identifyPermissionProject = async (
projectRoot: string,
): Promise<
Result<
{ readonly id: string; readonly root: string },
ProjectPermissionStoreError
>
> => {
try {
const root = await realpath(projectRoot);
return ok({
id: `project_${createHash("sha256").update(root).digest("hex")}`,
root,
});
} catch (cause: unknown) {
return err(new ProjectPermissionStoreError("project_not_found", { cause }));
}
};
/** Read and validate an owner-only store bound to the requested project. */
export const readProjectPermissionStore = async (
path: string,
projectRoot: string,
): Promise<
Result<ProjectPermissionStore | null, ProjectPermissionStoreError>
> => {
const project = await identifyPermissionProject(projectRoot);
if (!project.ok) return project;
try {
const metadata = await stat(path);
if ((metadata.mode & 0o077) !== 0)
return err(new ProjectPermissionStoreError("not_owner_only"));
const encoded = await readFile(path, "utf8");
let decoded: unknown;
try {
decoded = JSON.parse(encoded);
} catch (cause: unknown) {
return err(new ProjectPermissionStoreError("invalid", { cause }));
}
const parsed = storeSchema.safeParse(decoded);
if (
!parsed.success ||
parsed.data.project_id !== project.value.id ||
parsed.data.project_root !== project.value.root
)
return err(new ProjectPermissionStoreError("invalid"));
return ok(parsed.data);
} catch (cause: unknown) {
if (isNotFound(cause)) return ok(null);
return err(new ProjectPermissionStoreError("io", { cause }));
}
};
/** Atomically replace explicit project grants with owner-only permissions. */
export const writeProjectPermissionStore = async (
path: string,
projectRoot: string,
grants: readonly PermissionGrant[],
): Promise<Result<ProjectPermissionStore, ProjectPermissionStoreError>> => {
const project = await identifyPermissionProject(projectRoot);
if (!project.ok) return project;
const candidate = storeSchema.safeParse({
schema_version: 1,
project_id: project.value.id,
project_root: project.value.root,
grants,
});
if (!candidate.success)
return err(
new ProjectPermissionStoreError("invalid", { cause: candidate.error }),
);
try {
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
await writeFileAtomic(
path,
`${JSON.stringify(candidate.data, null, 2)}\n`,
{
encoding: "utf8",
mode: 0o600,
},
);
const metadata = await stat(path);
if ((metadata.mode & 0o077) !== 0) {
return err(new ProjectPermissionStoreError("not_owner_only"));
}
return ok(candidate.data);
} catch (cause: unknown) {
return err(new ProjectPermissionStoreError("io", { cause }));
}
};
/** Revoke one grant while serializing the complete read-modify-write cycle. */
export const revokeProjectPermissionGrant = async (
path: string,
projectRoot: string,
grantId: string,
): Promise<Result<boolean, ProjectPermissionStoreError>> => {
let lock: PermissionStoreLock | undefined;
try {
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
const acquired = await acquirePermissionStoreLock(path);
if (!acquired.ok) return acquired;
lock = acquired.value;
const current = await readProjectPermissionStore(path, projectRoot);
if (!current.ok) return current;
const grants = current.value?.grants ?? [];
const retained = grants.filter(({ grant_id }) => grant_id !== grantId);
if (retained.length === grants.length) return ok(false);
const written = await writeProjectPermissionStore(
path,
projectRoot,
retained,
);
return written.ok ? ok(true) : written;
} catch (cause: unknown) {
return err(new ProjectPermissionStoreError("io", { cause }));
} finally {
if (lock !== undefined) await releasePermissionStoreLock(lock);
}
};
interface PermissionStoreLock {
readonly path: string;
readonly handle: FileHandle;
}
const acquirePermissionStoreLock = async (
destination: string,
): Promise<Result<PermissionStoreLock, ProjectPermissionStoreError>> => {
const path = `${destination}.lock`;
for (let attempt = 0; attempt < 100; attempt += 1) {
try {
return ok(await createPermissionStoreLock(path));
} catch (cause: unknown) {
if (!isAlreadyExists(cause))
return err(new ProjectPermissionStoreError("io", { cause }));
if (await removeStalePermissionStoreLock(path)) continue;
if (attempt === 99)
return err(new ProjectPermissionStoreError("locked", { cause }));
await delay(10);
}
}
return err(new ProjectPermissionStoreError("locked"));
};
const createPermissionStoreLock = async (
path: string,
): Promise<PermissionStoreLock> => {
const handle = await open(path, "wx", 0o600);
try {
await handle.writeFile(`${String(process.pid)}\n`, "utf8");
await handle.sync();
return { path, handle };
} catch (cause: unknown) {
await handle.close().catch(() => undefined);
await unlink(path).catch(() => undefined);
throw cause;
}
};
const removeStalePermissionStoreLock = async (
path: string,
): Promise<boolean> => {
try {
const metadata = await lstat(path);
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > 32)
return false;
const encoded = (await readFile(path, "utf8")).trim();
if (!/^[1-9][0-9]{0,9}$/u.test(encoded)) return false;
if (processIsAlive(Number(encoded))) return false;
await unlink(path);
return true;
} catch {
return false;
}
};
const processIsAlive = (pid: number): boolean => {
try {
process.kill(pid, 0);
return true;
} catch (cause: unknown) {
return errorCode(cause) !== "ESRCH";
}
};
const releasePermissionStoreLock = async (
lock: PermissionStoreLock,
): Promise<void> => {
await lock.handle.close().catch(() => undefined);
await unlink(lock.path).catch(() => undefined);
};
const isNotFound = (cause: unknown): boolean => errorCode(cause) === "ENOENT";
const isAlreadyExists = (cause: unknown): boolean =>
errorCode(cause) === "EEXIST";
const errorCode = (cause: unknown): unknown =>
typeof cause === "object" && cause !== null && "code" in cause
? cause.code
: undefined;
+12 -14
View File
@@ -1,7 +1,6 @@
import { isSecretLikePath } from "../domain/referenceSourceClassification.js";
import {
createHistoricalSourceGraph,
referenceSourceParseFailureKey,
type HistoricalSourceGraph,
type HistoricalSourceGraphInput,
} from "../domain/referenceSourceGraph.js";
@@ -205,19 +204,18 @@ export const importReferenceSource = async (
if (isAborted(options.signal)) return err(cancelled());
const uniqueRelationships = deduplicateRelationships(relationships);
const uniqueFailures = [
...new Map(
parseFailures.map((parseFailure) => [
referenceSourceParseFailureKey(parseFailure),
parseFailure,
]),
).values(),
].sort((left, right) =>
byCodePoint(
referenceSourceParseFailureKey(left),
referenceSourceParseFailureKey(right),
),
);
const uniqueFailures = [...parseFailures]
.sort((left, right) => {
const byPath = byCodePoint(left.path, right.path);
if (byPath !== 0) return byPath;
return byCodePoint(left.parser, right.parser);
})
.filter((value, index, array) => {
if (index === 0) return true;
const previous = array[index - 1];
if (previous === undefined) return true;
return value.path !== previous.path || value.parser !== previous.parser;
});
const sortedEntries = [...entries].sort((left, right) =>
byCodePoint(left.path, right.path),
@@ -12,7 +12,6 @@ import {
type ReferenceSourceImportError,
type ReferenceSourceImportOptions,
} from "./ReferenceSourceImportTypes.js";
import { canonicalizeConfiguredRoots } from "./ConfiguredRoots.js";
/** Validated, authorized inputs ready for filesystem traversal. */
export interface PreparedReferenceSourceImport {
@@ -67,10 +66,8 @@ const authorizeRoot = async (
failure("invalid-root", "Reference source root is not a directory"),
);
const canonicalRoot = await realpath(resolve(requestedRoot));
const canonicalApprovedRoots = await canonicalizeConfiguredRoots(
approvedRoots.map((approvedRoot) => resolve(approvedRoot)),
);
for (const canonicalApproved of canonicalApprovedRoots) {
for (const approvedRoot of approvedRoots) {
const canonicalApproved = await realpath(resolve(approvedRoot));
if (withinRoot(canonicalApproved, canonicalRoot))
return ok(canonicalRoot);
}
+60 -24
View File
@@ -8,7 +8,7 @@ import {
writeFile,
} from "node:fs/promises";
import { homedir } from "node:os";
import { dirname, resolve } from "node:path";
import { dirname, join, resolve } from "node:path";
import { z } from "zod";
import writeFileAtomic from "write-file-atomic";
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
@@ -23,12 +23,7 @@ import {
} from "./LinuxHopper.js";
import { installMacHopper } from "./MacHopper.js";
import { configureDetectedClients } from "./SetupClients.js";
import { supportedClients, type SetupClient } from "./SupportedClients.js";
export type { SetupClient } from "./SupportedClients.js";
import {
canonicalSkillInstallationNeeded,
installCanonicalSkill,
} from "./SetupSkill.js";
import { installCanonicalSkill } from "./SetupSkill.js";
import { setupPlan } from "./SetupPlan.js";
import {
setupInstallFailure,
@@ -44,6 +39,13 @@ const registrationCommand = (): readonly string[] =>
? ["npx", "-y", PRODUCT_IDENTITY.packageName, "mcp"]
: [resolve(process.argv[1] ?? PRODUCT_IDENTITY.cliBinary), "mcp"];
/** A detected agent configuration owned by setup. */
export interface SetupClient {
readonly name: string;
readonly configPath: string;
readonly markerPath?: string;
readonly format?: "json" | "toml" | "unsupported";
}
/** Result of one backup/write/readback transaction. */
export type ClientConfigurationResult =
| {
@@ -72,7 +74,6 @@ export interface SetupHost {
hopperPath: string | undefined,
command: readonly string[],
): Promise<ClientConfigurationResult>;
skillInstallationNeeded(): Promise<boolean>;
installSkill(): Promise<"installed" | "unchanged" | "failed">;
doctor(): Promise<Awaited<ReturnType<typeof runDoctor>>>;
}
@@ -144,13 +145,7 @@ export const runSetup = async (
);
const installHopper = hopperPath === undefined || linuxHopperRepairNeeded;
const detectedClients = await host.detectedClients();
const installSkill = await host.skillInstallationNeeded();
plannedActions = setupPlan(
host.platform,
installHopper,
detectedClients,
installSkill,
);
plannedActions = setupPlan(host.platform, installHopper, detectedClients);
let approved = options.approved;
let interactiveApproval = false;
if (!approved && confirm !== undefined && !options.structured) {
@@ -192,14 +187,12 @@ export const runSetup = async (
appliedActions,
});
if (clientFailure !== undefined) return fail(clientFailure);
if (installSkill) {
const skill = await host.installSkill();
if (skill === "failed")
return fail(
"REA analysis skill could not be installed or verified. Check permissions for `~/.agents/skills`, then rerun setup.",
);
if (skill === "installed") appliedActions.push("installed_skill");
}
const skill = await host.installSkill();
if (skill === "failed")
return fail(
"REA analysis skill could not be installed or verified. Check permissions for `~/.agents/skills`, then rerun setup.",
);
if (skill === "installed") appliedActions.push("installed_skill");
const doctor = await host.doctor();
const remediation = finalSetupRemediation(
host.platform,
@@ -273,7 +266,6 @@ const systemSetupHost = (): SetupHost => {
: client.format === "toml"
? configureTomlClient(client, hopperPath, command)
: configureJsonClient(client, hopperPath, command),
skillInstallationNeeded: () => canonicalSkillInstallationNeeded(homedir()),
installSkill: () => installCanonicalSkill(homedir()),
doctor: () => runDoctor(undefined, doctorHost),
};
@@ -290,6 +282,50 @@ export const detectClients = async (
return detected;
};
/** Describe every client location that setup or uninstall may own. */
export const supportedClients = (home: string): readonly SetupClient[] => [
{
name: "claude_code",
configPath: join(home, ".claude.json"),
markerPath: join(home, ".claude"),
},
{
name: "claude_desktop",
configPath: join(
home,
"Library/Application Support/Claude/claude_desktop_config.json",
),
markerPath: join(home, "Library/Application Support/Claude"),
},
{
name: "codex",
configPath: join(home, ".codex/config.toml"),
markerPath: join(home, ".codex"),
format: "toml" as const,
},
{
name: "cursor",
configPath: join(home, ".cursor/mcp.json"),
markerPath: join(home, ".cursor"),
},
{
name: "gemini_cli",
configPath: join(home, ".gemini/settings.json"),
markerPath: join(home, ".gemini"),
},
{
name: "windsurf",
configPath: join(home, ".codeium/windsurf/mcp_config.json"),
markerPath: join(home, ".codeium/windsurf"),
},
{
name: "devin",
configPath: join(home, ".devin"),
markerPath: join(home, ".devin"),
format: "unsupported" as const,
},
];
/** Back up, atomically update, and semantically read back one JSON MCP configuration. */
export const configureJsonClient = async (
client: SetupClient,
+4 -6
View File
@@ -14,7 +14,7 @@ const failedConfigurationMessage = (
return "Agent configuration could not be verified after writing. Repair the configuration file or restore its `.rea.backup`, then rerun setup.";
};
/** Configure every detected agent and report all failed transaction classes. */
/** Configure each detected agent, stopping after the first failed transaction. */
export const configureDetectedClients = async (options: {
readonly host: SetupHost;
readonly detectedClients: readonly SetupClient[];
@@ -23,7 +23,6 @@ export const configureDetectedClients = async (options: {
readonly clients: Record<string, ClientConfigurationResult>;
readonly appliedActions: string[];
}): Promise<string | undefined> => {
const failures = new Set<"backup" | "write" | "readback">();
for (const client of options.detectedClients) {
const result = await options.host.configureClient(
client,
@@ -31,11 +30,10 @@ export const configureDetectedClients = async (options: {
options.command,
);
options.clients[client.name] = result;
if (result.status === "failed") failures.add(result.reason);
if (result.status === "failed")
return failedConfigurationMessage(result.reason);
if (result.status === "configured")
options.appliedActions.push(`configured_${client.name}`);
}
return failures.size === 0
? undefined
: [...failures].map(failedConfigurationMessage).join(" ");
return undefined;
};
+1 -33
View File
@@ -6,11 +6,7 @@ export type SetupFailureCode =
| "launcher_missing"
| "runtime_dependency_unavailable"
| "unsupported_hopper_build"
| "setup_cancelled"
| "destination_exists"
| "mount_failed"
| "bundle_invalid"
| "copy_failed";
| "setup_cancelled";
export type SetupHopperInstallResult =
| { readonly status: "installed"; readonly launcherPath: string }
@@ -88,34 +84,6 @@ export const setupInstallFailure = (
remediation:
"Setup was cancelled before Hopper was installed. Rerun rea setup when ready.",
};
case "destination_exists":
return {
status: "failed",
code: "destination_exists",
remediation:
"The Hopper installation destination already exists and was not replaced. Reuse the existing installation or move it aside, then rerun rea setup.",
};
case "mount":
return {
status: "failed",
code: "mount_failed",
remediation:
"The verified Hopper disk image could not be mounted. Eject any stale Hopper image, check local disk access, then rerun rea setup.",
};
case "bundle":
return {
status: "failed",
code: "bundle_invalid",
remediation:
"The mounted package did not contain the expected Hopper application bundle. Retry setup; if it repeats, update REA before installing.",
};
case "copy":
return {
status: "failed",
code: "copy_failed",
remediation:
"The verified Hopper application could not be copied to the installation destination. Check destination permissions and free space, then rerun rea setup.",
};
default:
return {
status: "failed",
+6 -11
View File
@@ -5,7 +5,6 @@ export const setupPlan = (
platform: NodeJS.Platform,
installHopper: boolean,
clients: readonly SetupClient[],
installSkill: boolean,
): readonly SetupAction[] => [
...(installHopper
? [
@@ -33,14 +32,10 @@ export const setupPlan = (
external: false,
}),
),
...(installSkill
? [
{
kind: "install_skill" as const,
target: "~/.agents/skills/rea-analysis/SKILL.md",
detail: "Install or update the bundled REA analysis skill.",
external: false,
},
]
: []),
{
kind: "install_skill",
target: "~/.agents/skills/rea-analysis/SKILL.md",
detail: "Install or update the bundled REA analysis skill.",
external: false,
},
];
+13 -30
View File
@@ -4,43 +4,26 @@ import writeFileAtomic from "write-file-atomic";
import { PRODUCT_IDENTITY } from "../identity.js";
const canonicalSkillDestination = (home: string): string =>
join(home, ".agents/skills", PRODUCT_IDENTITY.skillName, "SKILL.md");
const readCanonicalSkill = (): Promise<string> =>
readFile(
new URL(
`../../skills/${PRODUCT_IDENTITY.skillName}/SKILL.md`,
import.meta.url,
),
"utf8",
);
/** Determine whether setup would need to install or update the canonical skill. */
export const canonicalSkillInstallationNeeded = async (
home: string,
): Promise<boolean> => {
try {
const [canonical, installed] = await Promise.all([
readCanonicalSkill(),
readFile(canonicalSkillDestination(home), "utf8").catch(() => undefined),
]);
return installed !== canonical;
} catch {
// Disclose the action when setup cannot prove the installed copy is aligned.
return true;
}
};
/** Transactionally install or upgrade the versioned canonical REA skill. */
export const installCanonicalSkill = async (
home: string,
): Promise<"installed" | "unchanged" | "failed"> => {
const destination = canonicalSkillDestination(home);
const destination = join(
home,
".agents/skills",
PRODUCT_IDENTITY.skillName,
"SKILL.md",
);
const backup = `${destination}.rea.backup`;
let original: string | undefined;
try {
const content = await readCanonicalSkill();
const content = await readFile(
new URL(
`../../skills/${PRODUCT_IDENTITY.skillName}/SKILL.md`,
import.meta.url,
),
"utf8",
);
original = await readFile(destination, "utf8").catch(() => undefined);
if (original === content) return "unchanged";
await mkdir(dirname(destination), { recursive: true });
-53
View File
@@ -1,53 +0,0 @@
import { join } from "node:path";
/** One supported client configuration location. */
export interface SetupClient {
readonly name: string;
readonly configPath: string;
readonly markerPath?: string;
readonly format?: "json" | "toml" | "unsupported";
}
/** Describe every client location that setup, doctor, or uninstall may inspect. */
export const supportedClients = (home: string): readonly SetupClient[] => [
{
name: "claude_code",
configPath: join(home, ".claude.json"),
markerPath: join(home, ".claude"),
},
{
name: "claude_desktop",
configPath: join(
home,
"Library/Application Support/Claude/claude_desktop_config.json",
),
markerPath: join(home, "Library/Application Support/Claude"),
},
{
name: "codex",
configPath: join(home, ".codex/config.toml"),
markerPath: join(home, ".codex"),
format: "toml",
},
{
name: "cursor",
configPath: join(home, ".cursor/mcp.json"),
markerPath: join(home, ".cursor"),
},
{
name: "gemini_cli",
configPath: join(home, ".gemini/settings.json"),
markerPath: join(home, ".gemini"),
},
{
name: "windsurf",
configPath: join(home, ".codeium/windsurf/mcp_config.json"),
markerPath: join(home, ".codeium/windsurf"),
},
{
name: "devin",
configPath: join(home, ".devin"),
markerPath: join(home, ".devin"),
format: "unsupported",
},
];
+1 -1
View File
@@ -69,7 +69,7 @@ export class TerminalRenderer {
/** Await all queued parsing and return immutable rendered observations. */
async frames(): Promise<readonly RenderedTerminalFrame[]> {
await this.#pending;
return structuredClone(this.#frames);
return this.#frames;
}
/** Whether a rendered observation exceeded its independent capture budget. */
+1 -1
View File
@@ -7,7 +7,7 @@ import writeFileAtomic from "write-file-atomic";
import { z } from "zod";
import { PRODUCT_IDENTITY } from "../identity.js";
import { supportedClients, type SetupClient } from "./SupportedClients.js";
import { supportedClients, type SetupClient } from "./Setup.js";
interface ManagedPathStats {
isSymbolicLink(): boolean;
-7
View File
@@ -52,8 +52,6 @@ export type UpgradeResult =
readonly versionCheck: "available" | "unavailable";
readonly installMethod: "npm";
readonly command: typeof UPGRADE_COMMAND;
readonly clientRestartRequired: true;
readonly remediation: string;
}
| {
readonly status: "failed";
@@ -104,9 +102,6 @@ export const runUpgrade = async (
versionCheck: latestVersion === undefined ? "unavailable" : "available",
installMethod: "npm",
command: UPGRADE_COMMAND,
clientRestartRequired: true,
remediation:
"Rerun rea setup to refresh registrations and the skill, then restart clients that may retain an older MCP server.",
};
};
@@ -194,8 +189,6 @@ const runCommand = (
): Promise<boolean> =>
new Promise((resolveResult) => {
const child = spawn(command, [...arguments_], {
// Reserve parent stdout for Incur's structured result. Array positions
// are child fds 0/1/2, so npm stdout and stderr both reach parent stderr.
stdio:
output === "human" ? "inherit" : ["inherit", process.stderr, "inherit"],
});
+1 -4
View File
@@ -17,10 +17,7 @@ export const createBinarySession = (
): BinarySession => {
return new BinarySession(
new CompositeProvider([
new ArtifactProvider(
config.artifactNativeMountEnabled,
config.artifactIntegrityContinueEnabled,
),
new ArtifactProvider(config.artifactNativeMountEnabled),
new NativeMacOSProvider(),
new HopperProvider(config, logger),
]),
+3 -18
View File
@@ -5,7 +5,6 @@ import {
type AnalysisProvider,
type CapabilityDescriptor,
type ProviderIdentity,
type ExecutionOptions,
} from "../application/AnalysisProvider.js";
import { inventoryArtifact } from "../application/ArtifactInventory.js";
import { extractArtifact } from "../application/ArtifactExtraction.js";
@@ -33,10 +32,7 @@ const IDENTITY: ProviderIdentity = Object.freeze(ARTIFACT_GRAPH_PROVIDER);
/** Read-only inventory and exclusively owned extraction provider. */
export class ArtifactProvider implements AnalysisProvider {
constructor(
private readonly nativeMountEnabled = false,
private readonly integrityContinueEnabled = false,
) {}
constructor(private readonly nativeMountEnabled = false) {}
readonly #capabilities: readonly CapabilityDescriptor[] = Object.freeze(
ARTIFACT_TOOL_CONTRACTS.map((contract) =>
@@ -80,11 +76,7 @@ export class ArtifactProvider implements AnalysisProvider {
}
createClient(target: BinaryTarget): AnalysisClient {
return new ArtifactClient(
target,
this.nativeMountEnabled,
this.integrityContinueEnabled,
);
return new ArtifactClient(target, this.nativeMountEnabled);
}
}
@@ -92,13 +84,12 @@ class ArtifactClient implements AnalysisClient {
constructor(
private readonly target: BinaryTarget,
private readonly nativeMountEnabled: boolean,
private readonly integrityContinueEnabled: boolean,
) {}
async execute(
operation: AnalysisOperation,
parameters: Readonly<Record<string, JsonValue>>,
options?: ExecutionOptions,
options?: { readonly signal?: AbortSignal },
) {
if (operation === "health")
return ok(createAnalysisExecution(null, IDENTITY));
@@ -160,12 +151,6 @@ class ArtifactClient implements AnalysisClient {
nativeMountApproved: parsed.native_mount_approved === true,
nativeMountEnabled: this.nativeMountEnabled,
},
integrity: {
mode: parsed.integrity_policy,
approved: parsed.integrity_continue_approved === true,
enabled: this.integrityContinueEnabled,
maxMismatches: parsed.max_integrity_mismatches,
},
},
);
return ok(
+1 -1
View File
@@ -94,7 +94,7 @@ export class MachOSliceArtifactReader implements ArtifactReader {
}
provenance(): readonly ArtifactCommand[] {
return this.#command === undefined ? [] : [structuredClone(this.#command)];
return this.#command === undefined ? [] : [this.#command];
}
close(): Promise<void> {
+1 -1
View File
@@ -103,7 +103,7 @@ export class NativeDmgArtifactReader implements ArtifactReader {
}
provenance(): readonly ArtifactCommand[] {
return structuredClone(this.#provenance);
return this.#provenance;
}
async close(): Promise<void> {
+3 -3
View File
@@ -107,7 +107,7 @@ export class SafeOutputTree {
}
get cleanup(): SafeOutputCleanup {
return structuredClone(this.#cleanup);
return this.#cleanup;
}
/** Stream one selected regular file with exact byte and digest verification. */
@@ -205,7 +205,7 @@ export class SafeOutputTree {
/** Remove only this operation's unsealed tree and verify absence. */
async rollback(): Promise<SafeOutputCleanup> {
if (this.#published) return structuredClone(this.#cleanup);
if (this.#published) return this.#cleanup;
await rm(this.#stagingRoot, { recursive: true, force: true });
const absent = await isAbsent(this.#stagingRoot);
this.#cleanup = {
@@ -218,7 +218,7 @@ export class SafeOutputTree {
"integrity",
"Extraction output cleanup could not be verified",
);
return structuredClone(this.#cleanup);
return this.#cleanup;
}
async #prepareParent(relativePath: string): Promise<string> {
-426
View File
@@ -1,426 +0,0 @@
import type { BrowserObservationPort } from "../application/BrowserObservationPort.js";
import type { ProviderIdentity } from "../application/AnalysisProvider.js";
import type { ExecutionOptions } from "../application/AnalysisProvider.js";
import {
browserTargetListSchema,
sanitizeBrowserUrl,
webPageInspectionSchema,
type BrowserTargetList,
type InspectWebPageInput,
type ListBrowserTargetsInput,
type WebPageInspection,
} from "../domain/browserObservation.js";
import { analyzeCapturedWebBundle } from "../domain/webBundleAnalyzer.js";
import {
webBundleAnalysisSchema,
type AnalyzeWebBundleInput,
type WebBundleAnalysis,
} from "../domain/webBundleAnalysis.js";
import {
webObservationSessionSchema,
type ObserveWebSessionInput,
type WebObservationSession,
} from "../domain/browserSession.js";
import {
webMcpDiscoverySchema,
type DiscoverWebMcpToolsInput,
type WebMcpDiscovery,
} from "../domain/webMcpDiscovery.js";
import {
compareWebCaptures,
webCaptureDiffSchema,
type CompareWebCapturesInput,
type WebCaptureDiff,
} from "../domain/webCaptureDiff.js";
import {
webScreenshotDiffSchema,
webScreenshotSchema,
type CaptureWebScreenshotInput,
type CompareWebScreenshotsInput,
type WebScreenshot,
type WebScreenshotDiff,
} from "../domain/webScreenshot.js";
import {
AnalysisError,
BrowserObservationError,
ProviderAdapterError,
type BrowserObservationOperation,
} from "../domain/errors.js";
import { err, ok, type Result } from "../domain/result.js";
import {
discoverCdpEndpoint,
hasCdpTargetWebSocket,
type CdpEndpointDiscovery,
type CdpEndpointTarget,
} from "./CdpEndpoint.js";
import {
closeCdpTargetSession,
openCdpTargetSession,
type CdpTargetSession,
} from "./CdpTargetSession.js";
import { capturePage, type CapturedPage } from "./CdpPageCapture.js";
import { fetchWebSourceMaps } from "./WebSourceMapFetcher.js";
import { observeCdpSession } from "./CdpObservationSession.js";
import { discoverWebMcp } from "./CdpWebMcpDiscovery.js";
import { captureCdpScreenshot } from "./CdpScreenshot.js";
import { comparePngScreenshots } from "./PngVisualDiff.js";
const IDENTITY: ProviderIdentity = {
id: "rea-cdp-browser",
name: "REA Chrome DevTools Protocol observation provider",
version: "2",
};
const CLEANUP_DOMAINS = ["Network", "Debugger", "Runtime", "Page"] as const;
/** Passive, origin-scoped browser observation through a user-owned CDP endpoint. */
export class CdpBrowserProvider implements BrowserObservationPort {
identity(): ProviderIdentity {
return IDENTITY;
}
async listTargets(
input: ListBrowserTargetsInput,
options: ExecutionOptions = {},
): Promise<Result<BrowserTargetList, AnalysisError>> {
try {
const discovery = await discoverCdpEndpoint(
input.cdp_endpoint,
"list_browser_targets",
options.signal,
);
const allowedOrigins = new Set(input.allowed_origins);
const selected = selectTargets(discovery, allowedOrigins);
const items = selected.allowed.slice(
input.offset,
input.offset + input.limit,
);
const nextOffset = input.offset + items.length;
return ok(
browserTargetListSchema.parse({
schema_version: 1,
browser: discovery.version,
targets: {
items,
offset: input.offset,
limit: input.limit,
total: selected.allowed.length,
next_offset:
nextOffset < selected.allowed.length ? nextOffset : null,
has_more: nextOffset < selected.allowed.length,
},
excluded: selected.excluded,
limitations: [
"Only page targets whose current URL matches an approved exact origin are listed.",
...(selected.unconnectable === 0
? []
: [
`${String(selected.unconnectable)} otherwise allowed page target(s) lacked a validated direct CDP WebSocket and were excluded.`,
]),
],
}),
);
} catch (cause: unknown) {
return err(providerError(cause, "list_browser_targets"));
}
}
async inspectPage(
input: InspectWebPageInput,
options: ExecutionOptions = {},
): Promise<Result<WebPageInspection, AnalysisError>> {
try {
const captured = await this.#capture(input, options, "inspect_web_page");
return ok(webPageInspectionSchema.parse(captured.inspection));
} catch (cause: unknown) {
return err(providerError(cause, "inspect_web_page"));
}
}
async analyzeBundle(
input: AnalyzeWebBundleInput,
options: ExecutionOptions = {},
): Promise<Result<WebBundleAnalysis, AnalysisError>> {
try {
const captured = await this.#capture(
input,
options,
"analyze_web_bundle",
);
const sourceMaps = input.fetch_source_maps
? await fetchWebSourceMaps(
captured.sourceMapRequests,
input,
options.signal,
)
: undefined;
return ok(
webBundleAnalysisSchema.parse(
analyzeCapturedWebBundle(captured.inspection, input, sourceMaps),
),
);
} catch (cause: unknown) {
return err(providerError(cause, "analyze_web_bundle"));
}
}
async observeSession(
input: ObserveWebSessionInput,
options: ExecutionOptions = {},
): Promise<Result<WebObservationSession, AnalysisError>> {
let targetSession: CdpTargetSession | undefined;
try {
const discovery = await discoverCdpEndpoint(
input.cdp_endpoint,
"observe_web_session",
options.signal,
);
const target = authorizeTarget(discovery, input);
targetSession = await openCdpTargetSession(
discovery,
target,
"observe_web_session",
options.signal,
);
return ok(
webObservationSessionSchema.parse(
await observeCdpSession({
connection: targetSession.connection,
sessionId: targetSession.sessionId,
discovery,
target,
input,
...(options.signal === undefined ? {} : { signal: options.signal }),
...(options.progress === undefined
? {}
: { progress: options.progress }),
}),
),
);
} catch (cause: unknown) {
return err(providerError(cause, "observe_web_session"));
} finally {
if (targetSession !== undefined)
await closeCdpTargetSession(targetSession, CLEANUP_DOMAINS);
}
}
async discoverWebMcpTools(
input: DiscoverWebMcpToolsInput,
options: ExecutionOptions = {},
): Promise<Result<WebMcpDiscovery, AnalysisError>> {
let targetSession: CdpTargetSession | undefined;
try {
const discovery = await discoverCdpEndpoint(
input.cdp_endpoint,
"discover_webmcp_tools",
options.signal,
);
const target = authorizeTarget(discovery, input);
targetSession = await openCdpTargetSession(
discovery,
target,
"discover_webmcp_tools",
options.signal,
);
return ok(
webMcpDiscoverySchema.parse(
await discoverWebMcp({
connection: targetSession.connection,
sessionId: targetSession.sessionId,
discovery,
target,
input,
...(options.signal === undefined ? {} : { signal: options.signal }),
...(options.progress === undefined
? {}
: { progress: options.progress }),
}),
),
);
} catch (cause: unknown) {
return err(providerError(cause, "discover_webmcp_tools"));
} finally {
if (targetSession !== undefined)
await closeCdpTargetSession(targetSession, CLEANUP_DOMAINS);
}
}
async compareCaptures(
input: CompareWebCapturesInput,
): Promise<Result<WebCaptureDiff, AnalysisError>> {
try {
return ok(webCaptureDiffSchema.parse(compareWebCaptures(input)));
} catch (cause: unknown) {
return err(providerError(cause, "compare_web_captures"));
}
}
async captureScreenshot(
input: CaptureWebScreenshotInput,
options: ExecutionOptions = {},
): Promise<Result<WebScreenshot, AnalysisError>> {
let targetSession: CdpTargetSession | undefined;
try {
const discovery = await discoverCdpEndpoint(
input.cdp_endpoint,
"capture_web_screenshot",
options.signal,
);
const target = authorizeTarget(discovery, input);
targetSession = await openCdpTargetSession(
discovery,
target,
"capture_web_screenshot",
options.signal,
);
return ok(
webScreenshotSchema.parse(
await captureCdpScreenshot({
connection: targetSession.connection,
sessionId: targetSession.sessionId,
discovery,
target,
input,
...(options.signal === undefined ? {} : { signal: options.signal }),
...(options.progress === undefined
? {}
: { progress: options.progress }),
}),
),
);
} catch (cause: unknown) {
return err(providerError(cause, "capture_web_screenshot"));
} finally {
if (targetSession !== undefined)
await closeCdpTargetSession(targetSession, CLEANUP_DOMAINS);
}
}
async compareScreenshots(
input: CompareWebScreenshotsInput,
): Promise<Result<WebScreenshotDiff, AnalysisError>> {
try {
return ok(webScreenshotDiffSchema.parse(comparePngScreenshots(input)));
} catch (cause: unknown) {
return err(providerError(cause, "compare_web_screenshots"));
}
}
async #capture(
input: InspectWebPageInput,
options: ExecutionOptions,
operation: "inspect_web_page" | "analyze_web_bundle",
): Promise<CapturedPage> {
let targetSession: CdpTargetSession | undefined;
try {
const discovery = await discoverCdpEndpoint(
input.cdp_endpoint,
operation,
options.signal,
);
const target = authorizeTarget(discovery, input);
targetSession = await openCdpTargetSession(
discovery,
target,
operation,
options.signal,
);
return await capturePage({
connection: targetSession.connection,
sessionId: targetSession.sessionId,
operation,
discovery,
target,
input,
...(options.signal === undefined ? {} : { signal: options.signal }),
...(options.progress === undefined
? {}
: { progress: options.progress }),
});
} finally {
if (targetSession !== undefined)
await closeCdpTargetSession(targetSession, CLEANUP_DOMAINS);
}
}
}
const selectTargets = (
discovery: CdpEndpointDiscovery,
allowedOrigins: ReadonlySet<string>,
): {
readonly allowed: readonly BrowserTargetList["targets"]["items"][number][];
readonly excluded: BrowserTargetList["excluded"];
readonly unconnectable: number;
} => {
const allowed = [];
let disallowedOrigin = 0;
let unsupportedUrl = 0;
let nonPage = 0;
let unconnectable = 0;
for (const target of discovery.targets) {
if (target.type !== "page") {
nonPage += 1;
continue;
}
const url = sanitizeBrowserUrl(target.url);
if (url.origin === null) {
unsupportedUrl += 1;
continue;
}
if (!allowedOrigins.has(url.origin)) {
disallowedOrigin += 1;
continue;
}
if (!hasCdpTargetWebSocket(discovery, target)) {
unconnectable += 1;
continue;
}
allowed.push({
target_id: target.id,
type: target.type,
title: target.title.slice(0, 16_384),
url: url.url,
origin: url.origin,
attached: target.attached,
});
}
allowed.sort((left, right) => left.target_id.localeCompare(right.target_id));
return {
allowed,
excluded: {
disallowed_origin: disallowedOrigin,
unsupported_url: unsupportedUrl,
non_page: nonPage,
},
unconnectable,
};
};
const authorizeTarget = (
discovery: CdpEndpointDiscovery,
input: Pick<InspectWebPageInput, "target_id" | "allowed_origins">,
): CdpEndpointTarget => {
const target = discovery.targets.find(
(candidate) => candidate.id === input.target_id,
);
if (target === undefined)
throw new BrowserObservationError("inspect_web_page", "target_not_found");
const origin = sanitizeBrowserUrl(target.url).origin;
if (
target.type !== "page" ||
origin === null ||
!input.allowed_origins.includes(origin)
)
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
return target;
};
const providerError = (
cause: unknown,
operation: BrowserObservationOperation,
): AnalysisError =>
cause instanceof BrowserObservationError && cause.operation !== operation
? new BrowserObservationError(operation, cause.reason, { cause })
: cause instanceof AnalysisError
? cause
: new ProviderAdapterError(IDENTITY.id, operation, { cause });
-119
View File
@@ -1,119 +0,0 @@
import {
classifyBrowserCompleteness,
type BrowserCompleteness,
type BrowserDroppedEventKind,
type BrowserDroppedEvents,
type BrowserExclusion,
type BrowserExclusionReason,
type BrowserSection,
} from "../domain/browserCompleteness.js";
const EVENT_SECTIONS: Readonly<
Record<BrowserDroppedEventKind, BrowserSection>
> = {
scripts: "scripts",
network_requests: "network_requests",
console_events: "console_events",
websocket_connections: "websocket_connections",
websocket_frames: "websocket_frames",
webmcp_tools: "webmcp_tools",
timeline_events: "timeline",
};
const POLICY_REASONS = new Set<BrowserExclusionReason>([
"disallowed_origin",
"unsupported_url",
"unattributed_origin",
"not_approved",
"out_of_target_scope",
]);
/** Mutable adapter ledger projected into immutable, canonical completeness v2. */
export class CdpCaptureCompleteness {
readonly #initialAttachLimited: readonly BrowserSection[];
readonly #policyFiltered = new Set<BrowserSection>();
readonly #attachLimited = new Set<BrowserSection>();
readonly #truncated = new Set<BrowserSection>();
readonly #unavailable = new Set<BrowserSection>();
readonly #excluded = new Map<string, BrowserExclusion>();
readonly #dropped: Omit<BrowserDroppedEvents, "total"> = emptyDropped();
constructor(initialAttachLimited: readonly BrowserSection[] = []) {
this.#initialAttachLimited = [...initialAttachLimited];
this.reset();
}
reset(): void {
this.#policyFiltered.clear();
this.#attachLimited.clear();
this.#truncated.clear();
this.#unavailable.clear();
this.#excluded.clear();
Object.assign(this.#dropped, emptyDropped());
for (const section of this.#initialAttachLimited)
this.#attachLimited.add(section);
}
exclude(
section: BrowserSection,
reason: BrowserExclusionReason,
count: number | null = 1,
): void {
if (POLICY_REASONS.has(reason)) this.#policyFiltered.add(section);
if (
reason === "provider_unavailable" ||
reason === "invalid_protocol_value"
)
this.#unavailable.add(section);
const key = `${section}\0${reason}`;
const existing = this.#excluded.get(key);
const combined =
count === null || existing?.count === null
? null
: (existing?.count ?? 0) + count;
this.#excluded.set(key, { section, reason, count: combined });
}
attachLimited(section: BrowserSection): void {
this.#attachLimited.add(section);
}
unavailable(section: BrowserSection): void {
this.#unavailable.add(section);
this.exclude(section, "provider_unavailable", null);
}
truncate(section: BrowserSection): void {
this.#truncated.add(section);
}
drop(kind: BrowserDroppedEventKind, count = 1): void {
this.#dropped[kind] += count;
this.#truncated.add(EVENT_SECTIONS[kind]);
}
get droppedTotal(): number {
return Object.values(this.#dropped).reduce((sum, count) => sum + count, 0);
}
snapshot(): BrowserCompleteness {
return classifyBrowserCompleteness({
policyFilteredSections: this.#policyFiltered,
attachLimitedSections: this.#attachLimited,
truncatedSections: this.#truncated,
unavailableSections: this.#unavailable,
excluded: [...this.#excluded.values()],
droppedEvents: this.#dropped,
});
}
}
const emptyDropped = (): Omit<BrowserDroppedEvents, "total"> => ({
scripts: 0,
network_requests: 0,
console_events: 0,
websocket_connections: 0,
websocket_frames: 0,
webmcp_tools: 0,
timeline_events: 0,
});
-422
View File
@@ -1,422 +0,0 @@
import type {
InspectWebPageInput,
WebPageInspection,
} from "../domain/browserObservation.js";
import {
allowedSanitizedUrl,
boundedText,
isHttpUrl,
numberValue,
recordValue,
recordsValue,
requiredRecord,
stringValue,
type UnknownRecord,
} from "./CdpCaptureValues.js";
import type { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
export type CapturedResource = Omit<
WebPageInspection["resources"][number],
"resource_key"
> & { readonly rawUrl: string };
/** Normalize allowed frames while retaining a bounded prefix and total count. */
export const captureFrames = (
result: unknown,
allowedOrigins: ReadonlySet<string>,
maximum: number,
completeness?: CdpCaptureCompleteness,
): {
readonly total: number;
readonly items: WebPageInspection["frames"];
} => {
const root = recordValue(requiredRecord(result).frameTree);
if (root === undefined) return { total: 0, items: [] };
const items: WebPageInspection["frames"] = [];
let total = 0;
for (const tree of walkFrameTrees(root)) {
const frame = recordValue(tree.frame);
const frameId = stringValue(frame?.id);
const sanitized = allowedSanitizedUrl(frame?.url, allowedOrigins);
if (frame === undefined || frameId === undefined || frameId.length > 256) {
completeness?.exclude("frames", "invalid_protocol_value");
continue;
}
if (sanitized === undefined || sanitized.origin === null) {
completeness?.exclude(
"frames",
exclusionReasonForUrl(stringValue(frame.url)),
);
continue;
}
total += 1;
if (items.length >= maximum) continue;
items.push({
frame_id: frameId,
parent_frame_id:
(stringValue(frame.parentId) ?? "").slice(0, 256) || null,
url: sanitized.url,
origin: sanitized.origin,
});
}
return { total, items };
};
/** Read the current main-frame URL from an untrusted Page.getFrameTree result. */
export const mainFrameUrl = (result: unknown): string | undefined =>
stringValue(
recordValue(recordValue(requiredRecord(result).frameTree)?.frame)?.url,
);
/** Normalize allowed resources while retaining a bounded prefix and total count. */
export const captureResources = (
result: unknown,
allowedOrigins: ReadonlySet<string>,
maximum: number,
completeness?: CdpCaptureCompleteness,
): {
readonly total: number;
readonly items: readonly CapturedResource[];
} => {
const root = recordValue(requiredRecord(result).frameTree);
if (root === undefined) return { total: 0, items: [] };
const items: CapturedResource[] = [];
let total = 0;
for (const tree of walkFrameTrees(root)) {
for (const resource of recordsValue(tree.resources)) {
const url = allowedSanitizedUrl(resource.url, allowedOrigins);
if (url === undefined || url.origin === null) {
completeness?.exclude(
"resources",
exclusionReasonForUrl(stringValue(resource.url)),
);
continue;
}
total += 1;
if (items.length >= maximum) continue;
const contentSize = numberValue(resource.contentSize);
items.push({
rawUrl: stringValue(resource.url) ?? "",
url: url.url,
origin: url.origin,
type: (stringValue(resource.type) ?? "Other").slice(0, 100),
mime_type: (stringValue(resource.mimeType) ?? "").slice(0, 256),
content_size:
contentSize === undefined ? null : Math.max(0, contentSize),
});
}
}
return { total, items };
};
const walkFrameTrees = function* (
root: UnknownRecord,
): Generator<UnknownRecord> {
const pending = [root];
while (pending.length > 0) {
const tree = pending.pop();
if (tree === undefined) return;
const children = recordsValue(tree.childFrames);
for (let index = children.length - 1; index >= 0; index -= 1) {
const child = children[index];
if (child !== undefined) pending.push(child);
}
yield tree;
}
};
export const captureDom = (
result: unknown,
allowedOrigins: ReadonlySet<string>,
input: InspectWebPageInput,
completeness?: CdpCaptureCompleteness,
): {
readonly total: number;
readonly nodes: WebPageInspection["dom"]["nodes"];
readonly urls: WebPageInspection["metadata"]["dom_urls"];
readonly agentHints: WebPageInspection["metadata"]["agent_hints"];
readonly excludedUrls: number;
} => {
const root = requiredRecord(result);
const strings = Array.isArray(root.strings)
? root.strings.map((value) => stringValue(value) ?? "")
: [];
const nodes: WebPageInspection["dom"]["nodes"] = [];
const urls: WebPageInspection["metadata"]["dom_urls"] = [];
const agentHints: WebPageInspection["metadata"]["agent_hints"] = [];
let excludedUrls = 0;
let total = 0;
for (const document of recordsValue(root.documents)) {
const documentUrl = indexedString(strings, document.documentURL);
if (allowedSanitizedUrl(documentUrl, allowedOrigins) === undefined) {
completeness?.exclude("dom", exclusionReasonForUrl(documentUrl));
continue;
}
const documentNodes = recordValue(document.nodes);
if (documentNodes === undefined) continue;
const nodeTypes = numberArray(documentNodes.nodeType);
const nodeNames = numberArray(documentNodes.nodeName);
const nodeValues = numberArray(documentNodes.nodeValue);
const parents = numberArray(documentNodes.parentIndex);
const attributes = Array.isArray(documentNodes.attributes)
? documentNodes.attributes
: [];
const baseIndex = nodes.length;
total += nodeTypes.length;
for (
let index = 0;
index < nodeTypes.length && nodes.length < input.limits.max_dom_nodes;
index += 1
) {
const attributeIndexes = numberArray(attributes[index]);
const parent = Math.trunc(parents[index] ?? -1);
const nodeIndex = nodes.length;
const nodeName = indexedString(strings, nodeNames[index]).slice(0, 256);
nodes.push({
index: nodeIndex,
parent_index: parent < 0 ? -1 : baseIndex + parent,
node_type: Math.max(0, Math.trunc(nodeTypes[index] ?? 0)),
node_name: nodeName,
node_value_length: indexedString(strings, nodeValues[index]).length,
attribute_names: attributeIndexes
.filter((_value, attributeIndex) => attributeIndex % 2 === 0)
.map((value) => indexedString(strings, value).slice(0, 256))
.slice(0, 200),
});
const metadata = domMetadata(
strings,
attributeIndexes,
documentUrl,
nodeIndex,
nodeName,
allowedOrigins,
);
for (const url of metadata.urls) {
if (urls.length >= input.limits.max_resources) {
completeness?.truncate("metadata");
continue;
}
urls.push(url);
if (url.destination_scope !== "approved") {
excludedUrls += 1;
completeness?.exclude(
"metadata",
url.destination_scope === "outside_policy"
? "disallowed_origin"
: "unsupported_url",
);
}
}
agentHints.push(...metadata.agentHints);
}
}
return {
total,
nodes,
urls,
agentHints: agentHints.slice(0, input.limits.max_resources),
excludedUrls,
};
};
export const captureAccessibility = (
results: readonly unknown[],
maximum: number,
options: {
readonly includeText: boolean;
readonly maximumFieldBytes: number;
readonly maximumTotalBytes: number;
readonly unavailable?: boolean;
},
): {
readonly total: number;
readonly nodes: WebPageInspection["accessibility"]["nodes"];
readonly textCapture: WebPageInspection["accessibility"]["text_capture"];
} => {
const all = results.flatMap((result) =>
recordsValue(requiredRecord(result).nodes),
);
let retainedBytes = 0;
let excludedFields = 0;
let truncatedFields = 0;
const nodes = all.slice(0, maximum).map((node) => {
const captureText = (value: unknown): string | null => {
const raw = stringValue(recordValue(value)?.value);
if (raw === undefined) return null;
if (!options.includeText) {
excludedFields += 1;
return null;
}
const available = Math.max(0, options.maximumTotalBytes - retainedBytes);
const captured = boundedUtf8(
raw,
Math.min(options.maximumFieldBytes, available),
);
retainedBytes += captured.bytes;
if (captured.truncated) {
truncatedFields += 1;
if (captured.text === "" && raw !== "") {
excludedFields += 1;
return null;
}
}
return captured.text;
};
return {
node_id: (stringValue(node.nodeId) ?? "").slice(0, 256),
parent_id: (stringValue(node.parentId) ?? "").slice(0, 256) || null,
role: axText(node.role),
name: captureText(node.name),
description: captureText(node.description),
ignored: node.ignored === true,
};
});
for (const node of all.slice(maximum)) {
for (const value of [node.name, node.description]) {
if (stringValue(recordValue(value)?.value) === undefined) continue;
excludedFields += 1;
if (options.includeText) truncatedFields += 1;
}
}
return {
total: all.length,
nodes,
textCapture: {
status: options.unavailable
? "unavailable"
: !options.includeText
? "not_approved"
: truncatedFields > 0
? "truncated"
: "included",
retained_bytes: retainedBytes,
excluded_fields: excludedFields,
truncated_fields: truncatedFields,
},
};
};
const axText = (value: unknown): string | null =>
boundedText(recordValue(value)?.value, 1_024);
const indexedString = (strings: readonly string[], index: unknown): string => {
const integer = numberValue(index);
return integer === undefined ? "" : (strings[Math.trunc(integer)] ?? "");
};
const numberArray = (value: unknown): readonly number[] =>
Array.isArray(value)
? value.flatMap((item) => {
const number = numberValue(item);
return number === undefined ? [] : [number];
})
: [];
const domMetadata = (
strings: readonly string[],
attributes: readonly number[],
documentUrl: string,
nodeIndex: number,
nodeName: string,
allowedOrigins: ReadonlySet<string>,
): {
readonly urls: WebPageInspection["metadata"]["dom_urls"];
readonly agentHints: WebPageInspection["metadata"]["agent_hints"];
} => {
const pairs = new Map<string, string>();
for (let index = 0; index + 1 < attributes.length; index += 2) {
const name = indexedString(strings, attributes[index]).toLowerCase();
const value = indexedString(strings, attributes[index + 1]);
pairs.set(name, value);
}
const urls: WebPageInspection["metadata"]["dom_urls"] = [];
for (const attribute of domUrlAttributes) {
const value = pairs.get(attribute);
if (value === undefined) continue;
const destination = domDestination(value, documentUrl, allowedOrigins);
urls.push({
node_index: nodeIndex,
attribute,
url: destination.url,
destination_scope: destination.scope,
});
}
const rel = (pairs.get("rel") ?? "")
.toLowerCase()
.split(/\s+/u)
.filter((value) => agentRelValues.includes(value));
const href = urls.find(({ attribute }) => attribute === "href")?.url ?? null;
const agentHints =
nodeName.toLowerCase() === "link" && rel.length > 0
? rel.map((declaration) => ({
mechanism: "dom_link_rel" as const,
declaration,
url: href,
trust: "page-declared-untrusted" as const,
}))
: [];
return { urls, agentHints };
};
const domDestination = (
value: string,
documentUrl: string,
allowedOrigins: ReadonlySet<string>,
): {
readonly url: string | null;
readonly scope: "approved" | "outside_policy" | "unsupported";
} => {
try {
const parsed = new URL(value, documentUrl);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
return { url: null, scope: "unsupported" };
if (!allowedOrigins.has(parsed.origin))
return { url: null, scope: "outside_policy" };
return {
url: allowedSanitizedUrl(parsed.href, allowedOrigins)?.url ?? null,
scope: "approved",
};
} catch {
return { url: null, scope: "unsupported" };
}
};
const boundedUtf8 = (
value: string,
maximumBytes: number,
): {
readonly text: string;
readonly bytes: number;
readonly truncated: boolean;
} => {
const total = Buffer.byteLength(value);
if (total <= maximumBytes)
return { text: value, bytes: total, truncated: false };
let text = "";
let bytes = 0;
for (const character of value) {
const characterBytes = Buffer.byteLength(character);
if (bytes + characterBytes > maximumBytes) break;
text += character;
bytes += characterBytes;
}
return { text, bytes, truncated: true };
};
const exclusionReasonForUrl = (
value: string | undefined,
): "disallowed_origin" | "unsupported_url" | "unattributed_origin" =>
value === undefined || value === ""
? "unattributed_origin"
: isHttpUrl(value)
? "disallowed_origin"
: "unsupported_url";
const domUrlAttributes = [
"href",
"src",
"action",
"formaction",
"poster",
] as const;
const agentRelValues = ["mcp", "model-context", "ai-plugin", "service-desc"];
-680
View File
@@ -1,680 +0,0 @@
import type {
InspectWebPageInput,
WebPageInspection,
} from "../domain/browserObservation.js";
import { inferJsonShape, type JsonShape } from "../domain/jsonShape.js";
import type { CdpEvent } from "./CdpConnection.js";
import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
import { safeResponseMetadata } from "./CdpSafeMetadata.js";
import { boundedSensitiveText } from "./SensitiveTextCapture.js";
import {
allowedSanitizedUrl,
boundedText,
isHttpUrl,
numberValue,
recordValue,
recordsValue,
stringValue,
type UnknownRecord,
} from "./CdpCaptureValues.js";
export interface CapturedScript {
readonly scriptId: string;
readonly rawUrl: string;
readonly url: string;
readonly origin: string | null;
readonly hash: string;
readonly length: number;
readonly isModule: boolean;
readonly language: string | null;
readonly sourceMapUrl: string | null;
readonly sourceMapRawUrl: string | null;
}
type NetworkState = WebPageInspection["network"]["requests"][number];
/** Bounded event accumulator that drops payload values at ingestion time. */
export class CdpCaptureEvents {
readonly scripts = new Map<string, CapturedScript>();
readonly network = new Map<string, NetworkState>();
readonly allowedWebSockets = new Set<string>();
readonly console: WebPageInspection["console"]["events"] = [];
readonly websockets: WebPageInspection["network"]["websocket_events"] = [];
readonly responseMetadata: WebPageInspection["metadata"]["responses"] = [];
readonly agentHints: WebPageInspection["metadata"]["agent_hints"] = [];
readonly completeness = new CdpCaptureCompleteness([
"network_requests",
"console_events",
"websocket_connections",
"websocket_frames",
"metadata",
]);
originViolation = false;
navigationDuringCapture = false;
#mainFrameId: string | undefined;
#consoleTextBytes = 0;
#jsonBodyBytes = 0;
#websocketShapeBytes = 0;
constructor(
private readonly input: InspectWebPageInput,
private readonly allowedOrigins: ReadonlySet<string>,
) {}
beginAuthorizedFrame(frameId: string): void {
this.#mainFrameId = frameId;
this.originViolation = false;
this.navigationDuringCapture = false;
this.scripts.clear();
this.network.clear();
this.allowedWebSockets.clear();
this.console.length = 0;
this.websockets.length = 0;
this.responseMetadata.length = 0;
this.agentHints.length = 0;
this.#consoleTextBytes = 0;
this.#jsonBodyBytes = 0;
this.#websocketShapeBytes = 0;
this.completeness.reset();
if (!this.input.include_console_text)
this.completeness.exclude("console_text", "not_approved", null);
if (!this.input.include_json_body_shapes)
this.completeness.exclude("json_body_shapes", "not_approved", null);
if (!this.input.include_websocket_shapes)
this.completeness.exclude("websocket_shapes", "not_approved", null);
}
beginFinalCapture(frameId: string): void {
this.#mainFrameId = frameId;
this.navigationDuringCapture = false;
}
ingest(event: CdpEvent): void {
const params = recordValue(event.params);
if (params === undefined) return;
switch (event.method) {
case "Debugger.scriptParsed":
this.#script(params);
break;
case "Network.requestWillBeSent":
this.#request(params);
break;
case "Network.responseReceived":
this.#response(params);
break;
case "Network.loadingFinished":
this.#finished(params);
break;
case "Runtime.consoleAPICalled":
this.#console(params);
break;
case "Page.frameNavigated":
this.#frameNavigated(params);
break;
case "Network.webSocketCreated":
this.#websocketCreated(params);
break;
case "Network.webSocketFrameSent":
this.#websocket(params, "sent");
break;
case "Network.webSocketFrameReceived":
this.#websocket(params, "received");
break;
}
}
#script(params: UnknownRecord): void {
const scriptId = stringValue(params.scriptId);
const rawUrl = stringValue(params.url) ?? "";
const sanitized = allowedSanitizedUrl(rawUrl, this.allowedOrigins);
if (scriptId === undefined || scriptId.length > 256) {
this.completeness.exclude("scripts", "invalid_protocol_value");
return;
}
if (sanitized === undefined) {
this.completeness.exclude("scripts", exclusionReasonForUrl(rawUrl));
return;
}
if (
this.scripts.size >= this.input.limits.max_scripts &&
!this.scripts.has(scriptId)
) {
this.completeness.drop("scripts");
return;
}
const sourceMap = this.#sourceMap(params.sourceMapURL, rawUrl);
this.scripts.set(scriptId, {
scriptId,
rawUrl,
url: sanitized.url,
origin: sanitized.origin,
hash: (stringValue(params.hash) ?? "").slice(0, 512),
length: Math.max(
0,
Math.min(
Number.MAX_SAFE_INTEGER,
Math.trunc(numberValue(params.length) ?? 0),
),
),
isModule: params.isModule === true,
language: boundedText(params.scriptLanguage, 100),
sourceMapUrl: sourceMap.sanitized,
sourceMapRawUrl: sourceMap.raw,
});
}
#sourceMap(
value: unknown,
scriptUrl: string,
): {
readonly sanitized: string | null;
readonly raw: string | null;
} {
const declaredUrl = stringValue(value);
if (declaredUrl === undefined || declaredUrl === "")
return { sanitized: null, raw: null };
let rawUrl: string;
try {
rawUrl = new URL(declaredUrl, scriptUrl).href;
} catch {
this.completeness.exclude("source_maps", "unsupported_url");
return { sanitized: null, raw: null };
}
const sanitized = allowedSanitizedUrl(rawUrl, this.allowedOrigins);
if (sanitized !== undefined)
return { sanitized: sanitized.url, raw: rawUrl };
this.completeness.exclude("source_maps", exclusionReasonForUrl(rawUrl));
return { sanitized: null, raw: null };
}
#request(params: UnknownRecord): void {
const requestId = stringValue(params.requestId);
const request = recordValue(params.request);
if (requestId === undefined || requestId.length > 256) {
this.completeness.exclude("network_requests", "invalid_protocol_value");
return;
}
if (request === undefined) {
this.completeness.exclude("network_requests", "invalid_protocol_value");
this.network.delete(requestId);
return;
}
const sanitized = allowedSanitizedUrl(request.url, this.allowedOrigins);
if (sanitized === undefined) {
this.completeness.exclude(
"network_requests",
exclusionReasonForUrl(stringValue(request.url)),
);
this.network.delete(requestId);
return;
}
if (
this.network.size >= this.input.limits.max_network_events &&
!this.network.has(requestId)
) {
this.completeness.drop("network_requests");
return;
}
const initiator = recordValue(params.initiator);
const initiatorFrame = initiatorLocation(initiator);
const rawInitiatorUrl = stringValue(initiatorFrame?.url);
const initiatorUrl = allowedSanitizedUrl(
rawInitiatorUrl,
this.allowedOrigins,
);
if (rawInitiatorUrl !== undefined && initiatorUrl === undefined)
this.completeness.exclude(
"network_initiators",
exclusionReasonForUrl(rawInitiatorUrl),
);
this.network.set(requestId, {
request_id: requestId,
url: sanitized.url,
origin: sanitized.origin ?? "",
method: (stringValue(request.method) ?? "GET").slice(0, 32),
resource_type: boundedText(params.type, 100),
status: null,
mime_type: null,
encoded_data_length: null,
initiator: {
type: (stringValue(initiator?.type) ?? "other").slice(0, 100),
url: initiatorUrl?.url ?? null,
line: integerOrNull(initiatorFrame?.lineNumber),
column: integerOrNull(initiatorFrame?.columnNumber),
},
body_shapes: this.#requestBodyShape(request),
});
}
#response(params: UnknownRecord): void {
const requestId = stringValue(params.requestId);
if (requestId === undefined || requestId.length > 256) {
this.completeness.exclude("network_requests", "invalid_protocol_value");
return;
}
const current = this.network.get(requestId);
const response = recordValue(params.response);
if (current === undefined) return;
const sanitized = allowedSanitizedUrl(response?.url, this.allowedOrigins);
if (response === undefined || sanitized === undefined) {
this.completeness.exclude(
"network_requests",
response === undefined
? "invalid_protocol_value"
: exclusionReasonForUrl(stringValue(response.url)),
);
this.network.delete(requestId);
return;
}
this.network.set(requestId, {
...current,
status: numberValue(response.status) ?? null,
mime_type: boundedText(response.mimeType, 256),
});
if (this.responseMetadata.length >= this.input.limits.max_network_events) {
this.completeness.truncate("metadata");
return;
}
const metadata = safeResponseMetadata(
requestId,
sanitized.url,
response,
this.allowedOrigins,
);
this.responseMetadata.push(metadata.response);
this.agentHints.push(...metadata.agentHints);
}
#finished(params: UnknownRecord): void {
const requestId = stringValue(params.requestId);
if (requestId === undefined || requestId.length > 256) return;
const current = this.network.get(requestId);
if (current === undefined) return;
this.network.set(requestId, {
...current,
encoded_data_length: Math.max(
0,
numberValue(params.encodedDataLength) ?? 0,
),
});
}
#console(params: UnknownRecord): void {
const frame = firstCallFrame(recordValue(params.stackTrace));
if (frame === undefined) {
this.completeness.exclude("console_events", "unattributed_origin");
return;
}
const source = allowedSanitizedUrl(frame.url, this.allowedOrigins);
if (source === undefined) {
this.completeness.exclude(
"console_events",
exclusionReasonForUrl(stringValue(frame.url)),
);
return;
}
if (this.console.length >= this.input.limits.max_console_events) {
this.completeness.drop("console_events");
return;
}
const arguments_ = recordsValue(params.args);
this.console.push({
type: (stringValue(params.type) ?? "unknown").slice(0, 100),
timestamp: numberValue(params.timestamp) ?? 0,
argument_types: arguments_
.map((argument) =>
(stringValue(argument.type) ?? "unknown").slice(0, 100),
)
.slice(0, 100),
url: source.url,
line: integerOrNull(frame.lineNumber),
column: integerOrNull(frame.columnNumber),
text_capture: this.#consoleText(arguments_),
});
}
#websocket(params: UnknownRecord, direction: "sent" | "received"): void {
const requestId = stringValue(params.requestId);
if (
requestId === undefined ||
requestId.length > 256 ||
(!this.network.has(requestId) && !this.allowedWebSockets.has(requestId))
)
return;
if (this.websockets.length >= this.input.limits.max_websocket_events) {
this.completeness.drop("websocket_frames");
return;
}
const response = recordValue(params.response);
const payload = stringValue(response?.payloadData) ?? "";
const opcode = Math.max(0, Math.trunc(numberValue(response?.opcode) ?? 0));
const decoded = opcode === 1 ? undefined : decodeBase64(payload);
if (opcode !== 1 && decoded === undefined)
this.completeness.exclude("websocket_frames", "invalid_protocol_value");
this.websockets.push({
request_id: requestId,
direction,
opcode,
payload_bytes:
opcode === 1 ? Buffer.byteLength(payload) : (decoded?.byteLength ?? 0),
payload_shape: this.#websocketShape(payload, opcode),
});
}
responseBodyRequestIds(): readonly string[] {
if (!this.input.include_json_body_shapes) return [];
return [...this.network.values()]
.filter((request) => isJsonMediaType(request.mime_type))
.map((request) => request.request_id);
}
responseBodyUnavailable(requestId: string): void {
this.#updateResponseShape(requestId, null, false);
this.completeness.unavailable("json_body_shapes");
}
ingestResponseBody(requestId: string, value: unknown): void {
const result = recordValue(value);
const body = stringValue(result?.body);
if (body === undefined) {
this.#invalidResponseBody(requestId);
return;
}
const decoded =
result?.base64Encoded === true
? decodeBase64(body)?.toString("utf8")
: body;
if (decoded === undefined) {
this.#invalidResponseBody(requestId);
return;
}
const inferred = this.#inferBodyShape(decoded);
this.#updateResponseShape(requestId, inferred.shape, inferred.truncated);
}
#invalidResponseBody(requestId: string): void {
this.#updateResponseShape(requestId, null, false);
this.completeness.exclude("json_body_shapes", "invalid_protocol_value");
}
#requestBodyShape(request: UnknownRecord): NetworkState["body_shapes"] {
if (!this.input.include_json_body_shapes)
return { status: "not_approved", request: null, response: null };
const body = stringValue(request.postData);
if (!isJsonContentType(recordValue(request.headers)) || body === undefined)
return { status: "unavailable", request: null, response: null };
const inferred = this.#inferBodyShape(body);
return {
status: inferred.truncated
? "truncated"
: inferred.shape === null
? "unavailable"
: "included",
request: inferred.shape,
response: null,
};
}
#inferBodyShape(text: string): {
readonly shape: JsonShape | null;
readonly truncated: boolean;
} {
const bytes = Buffer.byteLength(text);
const remaining =
this.input.limits.max_total_json_body_bytes - this.#jsonBodyBytes;
if (bytes > this.input.limits.max_json_body_bytes || bytes > remaining) {
this.completeness.truncate("json_body_shapes");
return { shape: null, truncated: true };
}
this.#jsonBodyBytes += bytes;
const shape = inferJsonShape(text, {
maximumBytes: this.input.limits.max_json_body_bytes,
maximumNodes: this.input.limits.max_json_shape_nodes,
maximumDepth: this.input.limits.max_json_shape_depth,
});
if (shape?.truncated === true)
this.completeness.truncate("json_body_shapes");
return { shape, truncated: shape?.truncated === true };
}
#updateResponseShape(
requestId: string,
response: JsonShape | null,
truncated: boolean,
): void {
const current = this.network.get(requestId);
if (current === undefined) return;
const request = current.body_shapes.request;
const status =
truncated || current.body_shapes.status === "truncated"
? "truncated"
: response !== null
? "included"
: request !== null
? "partial"
: "unavailable";
this.network.set(requestId, {
...current,
body_shapes: { status, request, response },
});
}
#consoleText(
arguments_: readonly UnknownRecord[],
): WebPageInspection["console"]["events"][number]["text_capture"] {
if (!this.input.include_console_text)
return {
status: "not_approved",
values: [],
retained_bytes: 0,
truncated_values: 0,
};
const values: WebPageInspection["console"]["events"][number]["text_capture"]["values"] =
[];
let retainedBytes = 0;
let truncatedValues = 0;
for (const [argumentIndex, argument] of arguments_.entries()) {
const primitive = consolePrimitive(argument);
if (primitive === undefined) continue;
const remaining =
this.input.limits.max_total_console_text_bytes - this.#consoleTextBytes;
if (remaining <= 0) {
truncatedValues += 1;
continue;
}
const bounded = boundedSensitiveText(
primitive.text,
Math.min(this.input.limits.max_console_text_field_bytes, remaining),
);
values.push({
argument_index: argumentIndex,
type: primitive.type,
text: bounded.text,
});
retainedBytes += bounded.bytes;
this.#consoleTextBytes += bounded.bytes;
if (bounded.truncated) truncatedValues += 1;
}
if (truncatedValues > 0) this.completeness.truncate("console_text");
return {
status: truncatedValues > 0 ? "truncated" : "included",
values,
retained_bytes: retainedBytes,
truncated_values: truncatedValues,
};
}
#websocketShape(
payload: string,
opcode: number,
): WebPageInspection["network"]["websocket_events"][number]["payload_shape"] {
if (!this.input.include_websocket_shapes) return null;
if (opcode !== 1)
return { format: "binary", json_shape: null, truncated: false };
const bytes = Buffer.byteLength(payload);
const remaining =
this.input.limits.max_total_websocket_shape_bytes -
this.#websocketShapeBytes;
if (
bytes > this.input.limits.max_websocket_shape_bytes ||
bytes > remaining
) {
this.completeness.truncate("websocket_shapes");
return { format: "text", json_shape: null, truncated: true };
}
this.#websocketShapeBytes += bytes;
const shape = inferJsonShape(payload, {
maximumBytes: this.input.limits.max_websocket_shape_bytes,
maximumNodes: this.input.limits.max_json_shape_nodes,
maximumDepth: this.input.limits.max_json_shape_depth,
});
if (shape?.truncated === true)
this.completeness.truncate("websocket_shapes");
return {
format: shape === null ? "text" : "json",
json_shape: shape,
truncated: shape?.truncated === true,
};
}
#websocketCreated(params: UnknownRecord): void {
const requestId = stringValue(params.requestId);
const rawUrl = stringValue(params.url);
if (requestId === undefined || requestId.length > 256) {
this.completeness.exclude(
"websocket_connections",
"invalid_protocol_value",
);
return;
}
if (rawUrl === undefined) {
this.completeness.exclude(
"websocket_connections",
"invalid_protocol_value",
);
return;
}
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
this.completeness.exclude("websocket_connections", "unsupported_url");
return;
}
if (parsed.protocol === "ws:") parsed.protocol = "http:";
else if (parsed.protocol === "wss:") parsed.protocol = "https:";
else {
this.completeness.exclude("websocket_connections", "unsupported_url");
return;
}
if (!this.allowedOrigins.has(parsed.origin)) {
this.completeness.exclude("websocket_connections", "disallowed_origin");
return;
}
if (
!this.allowedWebSockets.has(requestId) &&
this.allowedWebSockets.size >= this.input.limits.max_websocket_events
) {
this.completeness.drop("websocket_connections");
return;
}
this.allowedWebSockets.add(requestId);
}
#frameNavigated(params: UnknownRecord): void {
const frame = recordValue(params.frame);
if (stringValue(frame?.id) !== this.#mainFrameId) return;
this.navigationDuringCapture = true;
const rawUrl = stringValue(frame?.url);
if (
isHttpUrl(rawUrl) &&
allowedSanitizedUrl(rawUrl, this.allowedOrigins) === undefined
)
this.originViolation = true;
}
}
const firstCallFrame = (
value: UnknownRecord | undefined,
): UnknownRecord | undefined => recordsValue(value?.callFrames)[0];
const initiatorLocation = (
initiator: UnknownRecord | undefined,
): UnknownRecord | undefined => {
let stack = recordValue(initiator?.stack);
for (let depth = 0; depth < 32 && stack !== undefined; depth += 1) {
const frame = firstCallFrame(stack);
if (frame !== undefined) return frame;
stack = recordValue(stack.parent);
}
return stringValue(initiator?.url) === undefined ? undefined : initiator;
};
const exclusionReasonForUrl = (
value: string | undefined,
): "disallowed_origin" | "unsupported_url" | "unattributed_origin" =>
value === undefined || value === ""
? "unattributed_origin"
: isHttpUrl(value)
? "disallowed_origin"
: "unsupported_url";
const integerOrNull = (value: unknown): number | null => {
const number = numberValue(value);
return number === undefined ? null : Math.max(0, Math.trunc(number));
};
const isJsonContentType = (headers: UnknownRecord | undefined): boolean => {
if (headers === undefined) return false;
for (const [name, value] of Object.entries(headers))
if (
name.toLowerCase() === "content-type" &&
isJsonMediaType(stringValue(value))
)
return true;
return false;
};
const isJsonMediaType = (value: string | null | undefined): boolean => {
const mediaType = value?.split(";", 1)[0]?.trim().toLowerCase();
return (
mediaType === "application/json" || mediaType?.endsWith("+json") === true
);
};
const consolePrimitive = (
value: UnknownRecord,
): { readonly type: string; readonly text: string } | undefined => {
const type = stringValue(value.type);
switch (type) {
case "string":
return typeof value.value === "string"
? { type, text: value.value }
: undefined;
case "boolean":
return typeof value.value === "boolean"
? { type, text: String(value.value) }
: undefined;
case "number":
return typeof value.value === "number"
? { type, text: String(value.value) }
: typeof value.unserializableValue === "string"
? { type, text: value.unserializableValue }
: undefined;
case "bigint":
return typeof value.unserializableValue === "string"
? { type, text: value.unserializableValue }
: undefined;
case "undefined":
return { type, text: "undefined" };
default:
return undefined;
}
};
const BASE64 = /^(?:[A-Za-z\d+/]{4})*(?:[A-Za-z\d+/]{2}==|[A-Za-z\d+/]{3}=)?$/u;
const decodeBase64 = (value: string): Buffer | undefined =>
value.length % 4 === 0 && BASE64.test(value)
? Buffer.from(value, "base64")
: undefined;
-116
View File
@@ -1,116 +0,0 @@
import type {
InspectWebPageInput,
WebPageInspection,
} from "../domain/browserObservation.js";
import { CdpConnection } from "./CdpConnection.js";
import { optionalCdpCommand } from "./CdpOptionalCommand.js";
import {
numberValue,
recordValue,
recordsValue,
stringValue,
} from "./CdpCaptureValues.js";
/** Capture redacted, bounded storage metadata for one authorized origin. */
export const captureStorage = async (
context: {
readonly connection: CdpConnection;
readonly sessionId: string | undefined;
readonly input: InspectWebPageInput;
readonly signal?: AbortSignal;
},
origin: string,
limitations: string[],
): Promise<{
readonly value: WebPageInspection["storage"];
readonly truncated: boolean;
}> => {
const quota = recordValue(
await optionalCdpCommand(
context,
"Storage.getUsageAndQuota",
{ origin },
limitations,
),
);
const local = context.input.include_storage_keys
? await storageKeys(context, origin, true, limitations)
: [];
const session = context.input.include_storage_keys
? await storageKeys(context, origin, false, limitations)
: [];
const indexed = context.input.include_storage_keys
? stringArray(
recordValue(
await optionalCdpCommand(
context,
"IndexedDB.requestDatabaseNames",
{ securityOrigin: origin },
limitations,
),
)?.databaseNames,
)
: [];
const caches = context.input.include_storage_keys
? recordsValue(
recordValue(
await optionalCdpCommand(
context,
"CacheStorage.requestCacheNames",
{ securityOrigin: origin },
limitations,
),
)?.caches,
).flatMap((cache) => {
const name = stringValue(cache.cacheName);
return name === undefined ? [] : [name.slice(0, 1_024)];
})
: [];
const maximum = context.input.limits.max_storage_keys;
return {
value: {
origin,
usage_bytes: numberValue(quota?.usage) ?? null,
quota_bytes: numberValue(quota?.quota) ?? null,
local_storage_keys: local.slice(0, maximum),
session_storage_keys: session.slice(0, maximum),
indexed_db_names: indexed.slice(0, maximum),
cache_names: caches.slice(0, maximum),
values_redacted: true,
},
truncated: [local, session, indexed, caches].some(
(items) => items.length > maximum,
),
};
};
const storageKeys = async (
context: Parameters<typeof captureStorage>[0],
origin: string,
isLocalStorage: boolean,
limitations: string[],
): Promise<readonly string[]> => {
const result = recordValue(
await optionalCdpCommand(
context,
"DOMStorage.getDOMStorageItems",
{ storageId: { securityOrigin: origin, isLocalStorage } },
limitations,
),
);
return Array.isArray(result?.entries)
? result.entries.flatMap((entry) => {
if (!Array.isArray(entry)) return [];
const key = stringValue(entry[0]);
return key === undefined ? [] : [key.slice(0, 1_024)];
})
: [];
};
const stringArray = (value: unknown): readonly string[] =>
Array.isArray(value)
? value.flatMap((item) => {
const text = stringValue(item);
return text === undefined ? [] : [text.slice(0, 1_024)];
})
: [];
-96
View File
@@ -1,96 +0,0 @@
import {
AnalysisCancelledError,
BrowserObservationError,
type BrowserObservationOperation,
} from "../domain/errors.js";
import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
import { createWebTextArtifact } from "../domain/webContentArtifact.js";
export type UnknownRecord = Readonly<Record<string, unknown>>;
const isRecord = (value: unknown): value is UnknownRecord =>
typeof value === "object" && value !== null && !Array.isArray(value);
export const recordValue = (value: unknown): UnknownRecord | undefined =>
isRecord(value) ? value : undefined;
export const recordsValue = (value: unknown): readonly UnknownRecord[] =>
Array.isArray(value)
? value.flatMap((item) => {
const record = recordValue(item);
return record === undefined ? [] : [record];
})
: [];
export const stringValue = (value: unknown): string | undefined =>
typeof value === "string" ? value : undefined;
export const numberValue = (value: unknown): number | undefined =>
typeof value === "number" && Number.isFinite(value) ? value : undefined;
export const requiredRecord = (value: unknown): UnknownRecord => {
const record = recordValue(value);
if (record === undefined)
throw new BrowserObservationError("inspect_web_page", "protocol_error");
return record;
};
export const boundedText = (value: unknown, maximum = 1_024): string | null => {
const text = stringValue(value);
return text === undefined ? null : text.slice(0, maximum);
};
export const isHttpUrl = (value: string | undefined): boolean => {
if (value === undefined) return false;
try {
const protocol = new URL(value).protocol;
return protocol === "http:" || protocol === "https:";
} catch {
return false;
}
};
export const allowedSanitizedUrl = (
value: unknown,
allowedOrigins: ReadonlySet<string>,
): ReturnType<typeof sanitizeBrowserUrl> | undefined => {
const text = stringValue(value);
if (text === undefined) return undefined;
const sanitized = sanitizeBrowserUrl(text);
return sanitized.origin !== null && allowedOrigins.has(sanitized.origin)
? sanitized
: undefined;
};
export const sourceResult = (
source: string,
): { included: true; artifact: ReturnType<typeof createWebTextArtifact> } => ({
included: true,
artifact: createWebTextArtifact(source, "text/javascript"),
});
export const sourceExcluded = (
reason: string,
): { included: false; reason: string } => ({ included: false, reason });
export const delayWithCancellation = async (
durationMs: number,
operation: BrowserObservationOperation,
signal?: AbortSignal,
): Promise<void> => {
if (durationMs === 0) return;
if (signal?.aborted === true) throw new AnalysisCancelledError(operation);
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, durationMs);
const onAbort = (): void => {
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
reject(new AnalysisCancelledError(operation));
};
signal?.addEventListener("abort", onAbort, { once: true });
if (signal?.aborted === true) onAbort();
});
};
-292
View File
@@ -1,292 +0,0 @@
import WebSocket, { type RawData } from "ws";
import {
AnalysisCancelledError,
AnalysisError,
AnalysisTimeoutError,
BrowserObservationError,
type BrowserObservationOperation,
} from "../domain/errors.js";
export interface CdpEvent {
readonly method: string;
readonly params: unknown;
readonly sessionId?: string;
}
interface PendingCommand {
readonly resolve: (value: unknown) => void;
readonly reject: (error: AnalysisError) => void;
readonly timer: ReturnType<typeof setTimeout>;
readonly removeAbort: () => void;
}
const CONNECT_TIMEOUT_MS = 5_000;
const COMMAND_TIMEOUT_MS = 5_000;
const MAX_PAYLOAD_BYTES = 16 * 1_024 * 1_024;
const MAX_PENDING_COMMANDS = 128;
/** Correlated, bounded JSON-RPC transport for one CDP target operation. */
export class CdpConnection {
readonly #pending = new Map<number, PendingCommand>();
readonly #listeners = new Set<(event: CdpEvent) => void>();
readonly #disconnectListeners = new Set<() => void>();
#nextId = 1;
#closed = false;
#protocolFailed = false;
private constructor(
private readonly socket: WebSocket,
private readonly operation: BrowserObservationOperation,
) {
socket.on("message", (data) => this.#receive(data));
socket.on("close", () => this.#disconnect());
socket.on("error", () => this.#disconnect());
}
/** Connect to one already-validated loopback CDP WebSocket. */
static async connect(
url: string,
operation: BrowserObservationOperation,
signal?: AbortSignal,
): Promise<CdpConnection> {
if (signal?.aborted === true) throw new AnalysisCancelledError(operation);
const socket = new WebSocket(url, {
handshakeTimeout: CONNECT_TIMEOUT_MS,
maxPayload: MAX_PAYLOAD_BYTES,
perMessageDeflate: false,
});
await waitForOpen(socket, operation, signal);
return new CdpConnection(socket, operation);
}
/** Subscribe to validated CDP event envelopes. */
onEvent(listener: (event: CdpEvent) => void): () => void {
this.#listeners.add(listener);
return () => this.#listeners.delete(listener);
}
/** Subscribe to an unexpected transport loss while an operation is active. */
onDisconnect(listener: () => void): () => void {
if (this.#closed) {
listener();
return () => undefined;
}
this.#disconnectListeners.add(listener);
return () => this.#disconnectListeners.delete(listener);
}
/** Execute one bounded command, optionally within a flat target session. */
async send(
method: string,
params: Readonly<Record<string, unknown>> = {},
sessionId?: string,
signal?: AbortSignal,
): Promise<unknown> {
if (this.#protocolFailed)
throw new BrowserObservationError(this.operation, "protocol_error");
if (this.#closed || this.socket.readyState !== WebSocket.OPEN)
throw new BrowserObservationError(this.operation, "disconnected");
if (this.#pending.size >= MAX_PENDING_COMMANDS)
throw new BrowserObservationError(this.operation, "payload_limit");
if (signal?.aborted === true)
throw new AnalysisCancelledError(this.operation);
const id = this.#nextId;
this.#nextId += 1;
return await new Promise((resolve, reject) => {
const onAbort = (): void => {
const pending = this.#pending.get(id);
if (pending === undefined) return;
clearTimeout(pending.timer);
pending.removeAbort();
this.#pending.delete(id);
reject(new AnalysisCancelledError(this.operation));
};
signal?.addEventListener("abort", onAbort, { once: true });
const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
this.#pending.delete(id);
reject(new AnalysisTimeoutError(this.operation, COMMAND_TIMEOUT_MS));
}, COMMAND_TIMEOUT_MS);
this.#pending.set(id, {
resolve,
reject,
timer,
removeAbort: () => signal?.removeEventListener("abort", onAbort),
});
this.socket.send(
JSON.stringify({
id,
method,
params,
...(sessionId === undefined ? {} : { sessionId }),
}),
(error) => {
if (error === undefined || error === null) return;
const pending = this.#pending.get(id);
if (pending === undefined) return;
this.#complete(id, pending);
reject(
new BrowserObservationError(this.operation, "disconnected", {
cause: error,
}),
);
},
);
});
}
/** Close only REA's socket; never close the browser or selected page. */
async close(): Promise<void> {
if (this.#closed) return;
this.#closed = true;
this.#failPending("disconnected");
if (this.socket.readyState === WebSocket.CLOSED) return;
await new Promise<void>((resolve) => {
const timer = setTimeout(() => {
this.socket.terminate();
resolve();
}, 1_000);
this.socket.once("close", () => {
clearTimeout(timer);
resolve();
});
this.socket.close();
});
}
#receive(data: RawData): void {
let message: unknown;
try {
message = JSON.parse(rawText(data));
} catch {
this.#failPending("protocol_error");
return;
}
if (!isRecord(message)) {
this.#failPending("protocol_error");
return;
}
if ("id" in message) {
if (
typeof message.id !== "number" ||
!Number.isSafeInteger(message.id) ||
message.id < 1
) {
this.#failPending("protocol_error");
return;
}
this.#receiveResponse(message, message.id);
return;
}
if (
typeof message.method !== "string" ||
message.method.length === 0 ||
("params" in message && !isRecord(message.params)) ||
("sessionId" in message && typeof message.sessionId !== "string")
) {
this.#failPending("protocol_error");
return;
}
const event: CdpEvent = {
method: message.method,
params: "params" in message ? message.params : {},
...(typeof message.sessionId === "string"
? { sessionId: message.sessionId }
: {}),
};
for (const listener of this.#listeners) listener(event);
}
#receiveResponse(message: Record<string, unknown>, id: number): void {
const pending = this.#pending.get(id);
if (pending === undefined) return;
this.#complete(id, pending);
if ("error" in message) {
pending.reject(
new BrowserObservationError(this.operation, "protocol_error"),
);
return;
}
pending.resolve("result" in message ? message.result : {});
}
#complete(id: number, pending: PendingCommand): void {
clearTimeout(pending.timer);
pending.removeAbort();
this.#pending.delete(id);
}
#failPending(reason: "disconnected" | "protocol_error"): void {
if (reason === "disconnected") this.#closed = true;
else this.#protocolFailed = true;
for (const [id, pending] of this.#pending) {
this.#complete(id, pending);
pending.reject(new BrowserObservationError(this.operation, reason));
}
}
#disconnect(): void {
const wasClosed = this.#closed;
this.#failPending("disconnected");
if (wasClosed) return;
for (const listener of this.#disconnectListeners) listener();
this.#disconnectListeners.clear();
}
}
const rawText = (data: RawData): string =>
Array.isArray(data)
? Buffer.concat(data).toString("utf8")
: Buffer.isBuffer(data)
? data.toString("utf8")
: Buffer.from(data).toString("utf8");
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const waitForOpen = async (
socket: WebSocket,
operation: BrowserObservationOperation,
signal?: AbortSignal,
): Promise<void> =>
await new Promise((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
terminateSocket(socket);
reject(new AnalysisTimeoutError(operation, CONNECT_TIMEOUT_MS));
}, CONNECT_TIMEOUT_MS);
const onOpen = (): void => {
cleanup();
resolve();
};
const onFailure = (cause: unknown): void => {
cleanup();
reject(
signal?.aborted === true
? new AnalysisCancelledError(operation)
: new BrowserObservationError(operation, "endpoint_unreachable", {
cause,
}),
);
};
const onAbort = (): void => {
terminateSocket(socket);
onFailure(new AnalysisCancelledError(operation));
};
const cleanup = (): void => {
clearTimeout(timer);
socket.off("open", onOpen);
socket.off("error", onFailure);
signal?.removeEventListener("abort", onAbort);
};
socket.once("open", onOpen);
socket.once("error", onFailure);
signal?.addEventListener("abort", onAbort, { once: true });
if (signal?.aborted === true) onAbort();
});
const terminateSocket = (socket: WebSocket): void => {
socket.once("error", () => undefined);
socket.terminate();
};
-485
View File
@@ -1,485 +0,0 @@
import { createHash } from "node:crypto";
import canonicalize from "canonicalize";
import type { ProgressReporter } from "../application/ProgressReporter.js";
import type {
ElectronPageInspection,
InspectElectronPageInput,
} from "../domain/electronObservation.js";
import { BrowserObservationError } from "../domain/errors.js";
import { createWebTextArtifact } from "../domain/webContentArtifact.js";
import type { CdpConnection, CdpEvent } from "./CdpConnection.js";
import type { CdpEndpointDiscovery, CdpEndpointTarget } from "./CdpEndpoint.js";
import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
import {
boundedText,
delayWithCancellation,
numberValue,
recordValue,
recordsValue,
requiredRecord,
stringValue,
type UnknownRecord,
} from "./CdpCaptureValues.js";
import {
authorizedElectronFile,
canonicalElectronRoots,
} from "./ElectronFileScope.js";
interface ElectronContext {
readonly connection: CdpConnection;
readonly sessionId: string | undefined;
readonly discovery: CdpEndpointDiscovery;
readonly target: CdpEndpointTarget;
readonly input: InspectElectronPageInput;
readonly signal?: AbortSignal;
readonly progress?: ProgressReporter;
}
interface ScriptDraft {
readonly scriptId: string;
readonly rawUrl: string;
readonly hash: string;
readonly length: number;
readonly isModule: boolean;
readonly language: string | null;
}
/** Passively inspect one root-confined Electron file page. */
export const inspectCdpElectronPage = async (
context: ElectronContext,
): Promise<ElectronPageInspection> => {
const roots = await canonicalElectronRoots(context.input.allowed_file_roots);
const completeness = new CdpCaptureCompleteness();
const scripts: ScriptDraft[] = [];
let mainFrameId: string | undefined;
let navigationDuringCapture = false;
const removeListener = context.connection.onEvent((event) => {
if (event.sessionId !== context.sessionId) return;
ingestScript(event, scripts, completeness);
if (mainFrameId !== undefined && navigatedFrameId(event) === mainFrameId)
navigationDuringCapture = true;
});
const startedAt = new Date().toISOString();
try {
await report(context.progress, 1, "Authorizing Electron file frames");
await context.connection.send(
"Page.enable",
{},
context.sessionId,
context.signal,
);
const before = await context.connection.send(
"Page.getFrameTree",
{},
context.sessionId,
context.signal,
);
const frames = await captureFrames(
before,
roots,
context.input.limits.max_frames,
completeness,
);
const main = frames[0];
if (main === undefined)
throw new BrowserObservationError(
"inspect_web_page",
"target_not_allowed",
);
mainFrameId = main.frame_id;
await context.connection.send(
"Debugger.enable",
{ maxScriptsCacheSize: 4 * 1_024 * 1_024 },
context.sessionId,
context.signal,
);
await delayWithCancellation(
context.input.observation_ms,
"inspect_electron_page",
context.signal,
);
await report(context.progress, 2, "Capturing Electron structure");
const resources = await captureResources(context, roots, completeness);
const dom = await captureDom(context, roots, completeness);
const normalizedScripts = await captureScripts(
context,
roots,
scripts,
completeness,
);
const after = await context.connection.send(
"Page.getFrameTree",
{},
context.sessionId,
context.signal,
);
const afterMain = await mainFilePath(after, roots);
if (afterMain === undefined)
throw new BrowserObservationError(
"inspect_web_page",
"target_not_allowed",
);
if (navigationDuringCapture || afterMain !== main.file_path)
throw new BrowserObservationError("inspect_web_page", "target_changed");
await report(context.progress, 3, "Electron inspection complete");
return {
schema_version: 1,
browser: context.discovery.version,
target: {
target_id: context.target.id,
type: context.target.type,
title: context.target.title.slice(0, 16_384),
file_path: main.file_path,
attached: context.target.attached,
},
capture_window: {
started_at: startedAt,
ended_at: new Date().toISOString(),
observation_ms: context.input.observation_ms,
},
completeness: completeness.snapshot(),
frames,
dom,
scripts: normalizedScripts,
resources,
limitations: [
"Only canonical file paths contained by an approved root are retained.",
"REA does not evaluate renderer JavaScript, invoke Electron APIs, navigate, click, or close the page.",
"Script contents require separate source-capture approval and remain byte bounded.",
],
};
} finally {
removeListener();
}
};
const navigatedFrameId = (event: CdpEvent): string | undefined => {
const params = recordValue(event.params);
if (params === undefined) return undefined;
if (event.method === "Page.frameNavigated")
return stringValue(recordValue(params.frame)?.id);
if (event.method === "Page.navigatedWithinDocument")
return stringValue(params.frameId);
return undefined;
};
const ingestScript = (
event: CdpEvent,
scripts: ScriptDraft[],
completeness: CdpCaptureCompleteness,
): void => {
if (event.method !== "Debugger.scriptParsed") return;
const value = recordValue(event.params);
const scriptId = stringValue(value?.scriptId);
const rawUrl = stringValue(value?.url);
if (scriptId === undefined || scriptId.length > 256) {
completeness.exclude("scripts", "invalid_protocol_value");
return;
}
if (rawUrl === undefined || rawUrl === "") {
completeness.exclude("scripts", "unattributed_origin");
return;
}
if (scripts.length >= 2_000) {
completeness.drop("scripts");
return;
}
scripts.push({
scriptId,
rawUrl,
hash: (stringValue(value?.hash) ?? "").slice(0, 512),
length: nonnegativeInteger(value?.length),
isModule: value?.isModule === true,
language: boundedText(value?.scriptLanguage, 100),
});
};
const captureFrames = async (
result: unknown,
roots: readonly string[],
maximum: number,
completeness: CdpCaptureCompleteness,
): Promise<ElectronPageInspection["frames"]> => {
const frameTree = recordValue(requiredRecord(result).frameTree);
if (frameTree === undefined)
throw new BrowserObservationError("inspect_web_page", "protocol_error");
const pending: Array<{
readonly tree: UnknownRecord;
readonly parent: string | null;
}> = [{ tree: frameTree, parent: null }];
const frames: ElectronPageInspection["frames"] = [];
while (pending.length > 0) {
const current = pending.shift();
if (current === undefined) break;
const frame = recordValue(current.tree.frame);
const frameId = stringValue(frame?.id);
const path = await authorizedElectronFile(
stringValue(frame?.url) ?? "",
roots,
);
if (frameId === undefined || path === undefined) {
completeness.exclude("frames", "out_of_target_scope");
continue;
}
if (frames.length >= maximum) {
completeness.truncate("frames");
continue;
}
frames.push({
frame_id: frameId.slice(0, 256),
parent_frame_id: current.parent,
file_path: path,
});
for (const child of recordsValue(current.tree.childFrames))
pending.push({ tree: child, parent: frameId.slice(0, 256) });
}
return frames;
};
const captureResources = async (
context: ElectronContext,
roots: readonly string[],
completeness: CdpCaptureCompleteness,
): Promise<ElectronPageInspection["resources"]> => {
const result = await context.connection.send(
"Page.getResourceTree",
{},
context.sessionId,
context.signal,
);
const root = recordValue(requiredRecord(result).frameTree);
if (root === undefined)
throw new BrowserObservationError("inspect_web_page", "protocol_error");
const pending = [root];
const resources: ElectronPageInspection["resources"] = [];
const seen = new Set<string>();
while (pending.length > 0) {
const tree = pending.shift();
if (tree === undefined) break;
for (const resource of recordsValue(tree.resources)) {
const path = await authorizedElectronFile(
stringValue(resource.url) ?? "",
roots,
);
if (path === undefined) {
completeness.exclude("resources", "out_of_target_scope");
continue;
}
const item = {
file_path: path,
type: (stringValue(resource.type) ?? "Other").slice(0, 100),
mime_type: (stringValue(resource.mimeType) ?? "").slice(0, 256),
content_size:
numberValue(resource.contentSize) === undefined
? null
: Math.max(0, numberValue(resource.contentSize) ?? 0),
};
const resourceKey = `electron_resource_${digest(item)}`;
if (seen.has(resourceKey)) continue;
seen.add(resourceKey);
if (resources.length >= context.input.limits.max_resources) {
completeness.truncate("resources");
continue;
}
resources.push({
resource_key: resourceKey,
...item,
});
}
pending.push(...recordsValue(tree.childFrames));
}
return resources.sort((left, right) =>
left.resource_key.localeCompare(right.resource_key),
);
};
const captureDom = async (
context: ElectronContext,
roots: readonly string[],
completeness: CdpCaptureCompleteness,
): Promise<ElectronPageInspection["dom"]> => {
const result = requiredRecord(
await context.connection.send(
"DOMSnapshot.captureSnapshot",
{ computedStyles: [], includePaintOrder: false, includeDOMRects: false },
context.sessionId,
context.signal,
),
);
const strings = Array.isArray(result.strings)
? result.strings.map((value) => (typeof value === "string" ? value : ""))
: [];
const nodes: ElectronPageInspection["dom"]["nodes"] = [];
let total = 0;
for (const document of recordsValue(result.documents)) {
const documentUrl = stringAt(strings, document.documentURL);
if ((await authorizedElectronFile(documentUrl, roots)) === undefined) {
completeness.exclude("dom", "out_of_target_scope");
continue;
}
const nodeData = recordValue(document.nodes);
const nodeTypes = arrayValue(nodeData?.nodeType);
const nodeNames = arrayValue(nodeData?.nodeName);
const nodeValues = arrayValue(nodeData?.nodeValue);
const parents = arrayValue(nodeData?.parentIndex);
const attributes = arrayValue(nodeData?.attributes);
const baseIndex = nodes.length;
total += nodeTypes.length;
for (let index = 0; index < nodeTypes.length; index += 1) {
if (nodes.length >= context.input.limits.max_dom_nodes) continue;
const attributeIndexes = arrayValue(attributes[index]);
const parent = integer(parents[index], -1);
nodes.push({
index: nodes.length,
parent_index: parent < 0 ? -1 : baseIndex + parent,
node_type: integer(nodeTypes[index], 0),
node_name: stringAt(strings, nodeNames[index]).slice(0, 1_024),
node_value_length: Buffer.byteLength(
stringAt(strings, nodeValues[index]),
),
attribute_names: attributeIndexes
.filter((_value, attributeIndex) => attributeIndex % 2 === 0)
.map((value) => stringAt(strings, value).slice(0, 1_024))
.slice(0, 256),
});
}
}
if (total > nodes.length) completeness.truncate("dom");
return { total_nodes: total, nodes };
};
const captureScripts = async (
context: ElectronContext,
roots: readonly string[],
scripts: readonly ScriptDraft[],
completeness: CdpCaptureCompleteness,
): Promise<ElectronPageInspection["scripts"]> => {
let total = 0;
let sourceBytes = 0;
const items: ElectronPageInspection["scripts"]["items"] = [];
const seen = new Set<string>();
for (const script of scripts) {
const path = await authorizedElectronFile(script.rawUrl, roots);
if (path === undefined) {
completeness.exclude("scripts", "out_of_target_scope");
continue;
}
const identity = {
file_path: path,
cdp_hash: script.hash,
length: script.length,
is_module: script.isModule,
language: script.language,
};
const scriptKey = `electron_script_${digest(identity)}`;
if (seen.has(scriptKey)) continue;
seen.add(scriptKey);
total += 1;
if (items.length >= context.input.limits.max_scripts) {
completeness.drop("scripts");
continue;
}
let source: ElectronPageInspection["scripts"]["items"][number]["source"] = {
included: false,
reason: "source capture was not approved",
};
if (context.input.include_script_sources) {
if (
script.length > context.input.limits.max_script_source_bytes ||
sourceBytes + script.length >
context.input.limits.max_total_script_source_bytes
) {
source = {
included: false,
reason: "script source byte limit reached",
};
completeness.truncate("script_sources");
} else {
const result = requiredRecord(
await context.connection.send(
"Debugger.getScriptSource",
{ scriptId: script.scriptId },
context.sessionId,
context.signal,
),
);
const text = stringValue(result.scriptSource) ?? "";
const bytes = Buffer.byteLength(text);
if (
bytes > context.input.limits.max_script_source_bytes ||
sourceBytes + bytes >
context.input.limits.max_total_script_source_bytes
) {
source = {
included: false,
reason: "script source byte limit reached",
};
completeness.truncate("script_sources");
} else {
source = {
included: true,
artifact: createWebTextArtifact(text, "text/javascript"),
};
sourceBytes += bytes;
}
}
}
items.push({
script_key: scriptKey,
...identity,
source,
});
}
if (!context.input.include_script_sources)
completeness.exclude("script_sources", "not_approved", total);
return {
total,
items: items.sort((left, right) =>
left.script_key.localeCompare(right.script_key),
),
};
};
const mainFilePath = async (
result: unknown,
roots: readonly string[],
): Promise<string | undefined> => {
const frameTree = recordValue(requiredRecord(result).frameTree);
const frame = recordValue(frameTree?.frame);
return await authorizedElectronFile(stringValue(frame?.url) ?? "", roots);
};
const arrayValue = (value: unknown): readonly unknown[] =>
Array.isArray(value) ? value : [];
const stringAt = (strings: readonly string[], value: unknown): string =>
strings[integer(value, -1)] ?? "";
const integer = (value: unknown, fallback: number): number => {
const number = numberValue(value);
return number === undefined ? fallback : Math.trunc(number);
};
const nonnegativeInteger = (value: unknown): number =>
Math.max(0, integer(value, 0));
const digest = (value: unknown): string => {
const encoded = canonicalize(value);
if (encoded === undefined) throw new TypeError("Expected canonical JSON");
return createHash("sha256").update(encoded).digest("hex");
};
const report = async (
progress: ProgressReporter | undefined,
completed: number,
message: string,
): Promise<void> =>
await progress?.report({
phase: "browser_observation",
completed,
total: 3,
message,
...(completed === 3 ? { terminal: true } : {}),
});
-204
View File
@@ -1,204 +0,0 @@
import type {
ExecutionOptions,
ProviderIdentity,
} from "../application/AnalysisProvider.js";
import type { ElectronObservationPort } from "../application/ElectronObservationPort.js";
import {
electronPageInspectionSchema,
electronTargetListSchema,
type ElectronPageInspection,
type ElectronTargetList,
type InspectElectronPageInput,
type ListElectronTargetsInput,
} from "../domain/electronObservation.js";
import {
AnalysisError,
BrowserObservationError,
ProviderAdapterError,
type BrowserObservationOperation,
} from "../domain/errors.js";
import { err, ok, type Result } from "../domain/result.js";
import {
discoverCdpEndpoint,
hasCdpTargetWebSocket,
type CdpEndpointTarget,
} from "./CdpEndpoint.js";
import {
closeCdpTargetSession,
openCdpTargetSession,
type CdpTargetSession,
} from "./CdpTargetSession.js";
import { inspectCdpElectronPage } from "./CdpElectronInspection.js";
import {
authorizedElectronFile,
canonicalElectronRoots,
} from "./ElectronFileScope.js";
const IDENTITY: ProviderIdentity = {
id: "rea-cdp-electron",
name: "REA Electron file-page CDP observation provider",
version: "1",
};
/** Passive Electron provider with canonical file-root confinement. */
export class CdpElectronProvider implements ElectronObservationPort {
identity(): ProviderIdentity {
return IDENTITY;
}
async listTargets(
input: ListElectronTargetsInput,
options: ExecutionOptions = {},
): Promise<Result<ElectronTargetList, AnalysisError>> {
try {
const roots = await canonicalElectronRoots(input.allowed_file_roots);
const discovery = await discoverCdpEndpoint(
input.cdp_endpoint,
"list_electron_targets",
options.signal,
);
const allowed = [];
let outsideRoot = 0;
let unsupportedUrl = 0;
let nonPage = 0;
let unconnectable = 0;
for (const target of discovery.targets) {
if (target.type !== "page") {
nonPage += 1;
continue;
}
const path = await authorizedElectronFile(target.url, roots);
if (path === undefined) {
if (isFileUrl(target.url)) outsideRoot += 1;
else unsupportedUrl += 1;
continue;
}
if (!hasCdpTargetWebSocket(discovery, target)) {
unconnectable += 1;
continue;
}
allowed.push({
target_id: target.id,
type: target.type,
title: target.title.slice(0, 16_384),
file_path: path,
attached: target.attached,
});
}
allowed.sort((left, right) =>
left.target_id.localeCompare(right.target_id),
);
const items = allowed.slice(input.offset, input.offset + input.limit);
const next = input.offset + items.length;
return ok(
electronTargetListSchema.parse({
schema_version: 1,
browser: discovery.version,
targets: {
items,
offset: input.offset,
limit: input.limit,
total: allowed.length,
next_offset: next < allowed.length ? next : null,
has_more: next < allowed.length,
},
excluded: {
outside_root: outsideRoot,
unsupported_url: unsupportedUrl,
non_page: nonPage,
},
limitations: [
"Only page targets whose canonical file path is contained by an approved root are listed.",
...(unconnectable === 0
? []
: [
`${String(unconnectable)} otherwise allowed page target(s) lacked a validated direct CDP WebSocket and were excluded.`,
]),
],
}),
);
} catch (cause: unknown) {
return err(providerError(cause, "list_electron_targets"));
}
}
async inspectPage(
input: InspectElectronPageInput,
options: ExecutionOptions = {},
): Promise<Result<ElectronPageInspection, AnalysisError>> {
let targetSession: CdpTargetSession | undefined;
try {
const roots = await canonicalElectronRoots(input.allowed_file_roots);
const discovery = await discoverCdpEndpoint(
input.cdp_endpoint,
"inspect_electron_page",
options.signal,
);
const target = await authorizeTarget(
discovery.targets,
input.target_id,
roots,
);
targetSession = await openCdpTargetSession(
discovery,
target,
"inspect_electron_page",
options.signal,
);
return ok(
electronPageInspectionSchema.parse(
await inspectCdpElectronPage({
connection: targetSession.connection,
sessionId: targetSession.sessionId,
discovery,
target,
input,
...(options.signal === undefined ? {} : { signal: options.signal }),
...(options.progress === undefined
? {}
: { progress: options.progress }),
}),
),
);
} catch (cause: unknown) {
return err(providerError(cause, "inspect_electron_page"));
} finally {
if (targetSession !== undefined)
await closeCdpTargetSession(targetSession, ["Debugger", "Page"]);
}
}
}
const authorizeTarget = async (
targets: readonly CdpEndpointTarget[],
targetId: string,
roots: readonly string[],
): Promise<CdpEndpointTarget> => {
const target = targets.find(({ id }) => id === targetId);
if (target === undefined)
throw new BrowserObservationError("inspect_web_page", "target_not_found");
if (
target.type !== "page" ||
(await authorizedElectronFile(target.url, roots)) === undefined
)
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
return target;
};
const isFileUrl = (value: string): boolean => {
try {
return new URL(value).protocol === "file:";
} catch {
return false;
}
};
const providerError = (
cause: unknown,
operation: BrowserObservationOperation,
): AnalysisError =>
cause instanceof BrowserObservationError && cause.operation !== operation
? new BrowserObservationError(operation, cause.reason, { cause })
: cause instanceof AnalysisError
? cause
: new ProviderAdapterError(IDENTITY.id, operation, { cause });
-311
View File
@@ -1,311 +0,0 @@
import { request } from "node:http";
import { z } from "zod";
import {
AnalysisCancelledError,
AnalysisError,
AnalysisTimeoutError,
BrowserObservationError,
type BrowserObservationOperation,
} from "../domain/errors.js";
const HTTP_TIMEOUT_MS = 5_000;
const MAX_VERSION_BYTES = 64 * 1_024;
const MAX_TARGET_LIST_BYTES = 2 * 1_024 * 1_024;
const endpointVersionSchema = z.object({
Browser: z.string().min(1).max(1_024),
"Protocol-Version": z.string().min(1).max(100),
"User-Agent": z.string().max(16_384),
"V8-Version": z.string().max(1_024),
"WebKit-Version": z.string().max(1_024),
webSocketDebuggerUrl: z.string().min(1).max(2_048),
});
const endpointTargetSchema = z.object({
id: z.string().min(1).max(256),
type: z.string().min(1).max(100),
title: z.string().max(16_384).default(""),
url: z.string().max(65_536),
attached: z.boolean().default(false),
webSocketDebuggerUrl: z.string().min(1).max(2_048).optional(),
});
const endpointTargetsSchema = z.array(endpointTargetSchema).max(1_000);
/** Validated direct CDP WebSocket bound to one discovered page target. */
export interface CdpPageWebSocketEndpoint {
readonly scope: "page";
readonly targetId: string;
readonly url: string;
}
/** Validated CDP discovery socket and its command-routing scope. */
export type CdpWebSocketEndpoint =
| { readonly scope: "browser"; readonly url: string }
| CdpPageWebSocketEndpoint;
/** Bounded discovery target with an optional validated direct transport. */
export interface CdpEndpointTarget {
readonly id: string;
readonly type: string;
readonly title: string;
readonly url: string;
readonly attached: boolean;
readonly webSocket?: CdpPageWebSocketEndpoint;
}
export interface CdpEndpointDiscovery {
readonly webSocket: CdpWebSocketEndpoint;
readonly version: {
readonly product: string;
readonly protocol_version: string;
readonly revision: string;
readonly user_agent: string;
readonly js_version: string;
};
readonly targets: readonly CdpEndpointTarget[];
}
/** Read bounded CDP discovery endpoints without following redirects. */
export const discoverCdpEndpoint = async (
endpoint: string,
operation: BrowserObservationOperation,
signal?: AbortSignal,
): Promise<CdpEndpointDiscovery> => {
const versionInput = await readJson(
new URL("/json/version", endpoint),
MAX_VERSION_BYTES,
operation,
signal,
);
const targetsInput = await readJson(
new URL("/json/list", endpoint),
MAX_TARGET_LIST_BYTES,
operation,
signal,
);
const version = parseEndpointValue(
endpointVersionSchema,
versionInput,
operation,
);
const targets = parseEndpointValue(
endpointTargetsSchema,
targetsInput,
operation,
);
return {
webSocket: safeCdpWebSocketEndpoint(
endpoint,
version.webSocketDebuggerUrl,
operation,
),
version: {
product: version.Browser,
protocol_version: version["Protocol-Version"],
revision: version["WebKit-Version"],
user_agent: version["User-Agent"],
js_version: version["V8-Version"],
},
targets: targets.map((target) => ({
id: target.id,
type: target.type,
title: target.title,
url: target.url,
attached: target.attached,
...targetWebSocket(endpoint, target, operation),
})),
};
};
/** Select a browser attachment socket or a direct socket for one page target. */
export const cdpTargetWebSocket = (
discovery: CdpEndpointDiscovery,
target: CdpEndpointTarget,
operation: BrowserObservationOperation,
): CdpWebSocketEndpoint => {
const webSocket = availableCdpTargetWebSocket(discovery, target);
if (webSocket !== undefined) return webSocket;
throw new BrowserObservationError(operation, "invalid_endpoint_response");
};
/** Report whether discovery contains a validated transport for one target. */
export const hasCdpTargetWebSocket = (
discovery: CdpEndpointDiscovery,
target: CdpEndpointTarget,
): boolean => availableCdpTargetWebSocket(discovery, target) !== undefined;
const availableCdpTargetWebSocket = (
discovery: CdpEndpointDiscovery,
target: CdpEndpointTarget,
): CdpWebSocketEndpoint | undefined => {
if (discovery.webSocket.scope === "browser") return discovery.webSocket;
if (target.webSocket !== undefined) return target.webSocket;
return discovery.webSocket.targetId === target.id
? discovery.webSocket
: undefined;
};
const parseEndpointValue = <Output>(
schema: z.ZodType<Output>,
input: unknown,
operation: BrowserObservationOperation,
): Output => {
const parsed = schema.safeParse(input);
if (!parsed.success)
throw new BrowserObservationError(operation, "invalid_endpoint_response", {
cause: parsed.error,
});
return parsed.data;
};
const safeCdpWebSocketEndpoint = (
endpoint: string,
reported: string,
operation: BrowserObservationOperation,
): CdpWebSocketEndpoint => {
let parsed: URL;
try {
parsed = new URL(reported);
} catch (cause: unknown) {
throw new BrowserObservationError(operation, "invalid_endpoint_response", {
cause,
});
}
const trustedEndpoint = new URL(endpoint);
const path = cdpWebSocketPath(parsed.pathname);
if (
parsed.protocol !== "ws:" ||
parsed.port !== trustedEndpoint.port ||
parsed.username !== "" ||
parsed.password !== "" ||
parsed.search !== "" ||
parsed.hash !== "" ||
path === undefined
)
throw new BrowserObservationError(operation, "invalid_endpoint_response");
parsed.hostname = trustedEndpoint.hostname;
return path.scope === "browser"
? { scope: "browser", url: parsed.href }
: { scope: "page", targetId: path.targetId, url: parsed.href };
};
const cdpWebSocketPath = (
pathname: string,
):
| { readonly scope: "browser" }
| { readonly scope: "page"; targetId: string }
| undefined => {
const browserId = pathIdentifier(pathname, "/devtools/browser/", 1_024);
if (browserId !== undefined) return { scope: "browser" };
const targetId = pathIdentifier(pathname, "/devtools/page/", 256);
return targetId === undefined ? undefined : { scope: "page", targetId };
};
const pathIdentifier = (
pathname: string,
prefix: string,
maximumLength: number,
): string | undefined => {
if (!pathname.startsWith(prefix)) return undefined;
const identifier = pathname.slice(prefix.length);
return identifier.length > 0 &&
identifier.length <= maximumLength &&
!identifier.includes("/")
? identifier
: undefined;
};
const targetWebSocket = (
endpoint: string,
target: z.infer<typeof endpointTargetSchema>,
operation: BrowserObservationOperation,
): { readonly webSocket?: CdpPageWebSocketEndpoint } => {
if (target.webSocketDebuggerUrl === undefined) return {};
try {
const webSocket = safeCdpWebSocketEndpoint(
endpoint,
target.webSocketDebuggerUrl,
operation,
);
return webSocket.scope === "page" && webSocket.targetId === target.id
? { webSocket }
: {};
} catch {
return {};
}
};
const readJson = async (
url: URL,
maximumBytes: number,
operation: BrowserObservationOperation,
signal?: AbortSignal,
): Promise<unknown> =>
await new Promise((resolve, reject) => {
if (signal?.aborted === true) {
reject(new AnalysisCancelledError(operation));
return;
}
const request_ = request(
url,
{ method: "GET", signal, headers: { Accept: "application/json" } },
(response) => {
if (response.statusCode !== 200) {
response.resume();
reject(
new BrowserObservationError(operation, "invalid_endpoint_response"),
);
return;
}
const chunks: Buffer[] = [];
let bytes = 0;
response.on("data", (chunk: Buffer) => {
bytes += chunk.length;
if (bytes > maximumBytes) {
response.destroy(
new BrowserObservationError(operation, "payload_limit"),
);
return;
}
chunks.push(chunk);
});
response.on("end", () => {
try {
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
} catch (cause: unknown) {
reject(
new BrowserObservationError(
operation,
"invalid_endpoint_response",
{ cause },
),
);
}
});
response.on("error", (cause: unknown) =>
reject(endpointFailure(cause, operation, signal)),
);
},
);
request_.setTimeout(HTTP_TIMEOUT_MS, () =>
request_.destroy(new AnalysisTimeoutError(operation, HTTP_TIMEOUT_MS)),
);
request_.on("error", (cause: unknown) =>
reject(endpointFailure(cause, operation, signal)),
);
request_.end();
});
const endpointFailure = (
cause: unknown,
operation: BrowserObservationOperation,
signal?: AbortSignal,
): AnalysisError => {
if (cause instanceof AnalysisError) return cause;
if (signal?.aborted === true) return new AnalysisCancelledError(operation);
return new BrowserObservationError(operation, "endpoint_unreachable", {
cause,
});
};
-441
View File
@@ -1,441 +0,0 @@
import type { ProgressReporter } from "../application/ProgressReporter.js";
import type {
ObserveWebSessionInput,
WebObservationSession,
} from "../domain/browserSession.js";
import {
AnalysisCancelledError,
BrowserObservationError,
} from "../domain/errors.js";
import type { CdpEndpointDiscovery, CdpEndpointTarget } from "./CdpEndpoint.js";
import { CdpConnection, type CdpEvent } from "./CdpConnection.js";
import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
import {
allowedSanitizedUrl,
delayWithCancellation,
isHttpUrl,
numberValue,
recordValue,
stringValue,
type UnknownRecord,
} from "./CdpCaptureValues.js";
import { mainFrameUrl } from "./CdpCaptureDocuments.js";
interface ObservationContext {
readonly connection: CdpConnection;
readonly sessionId: string | undefined;
readonly discovery: CdpEndpointDiscovery;
readonly target: CdpEndpointTarget;
readonly input: ObserveWebSessionInput;
readonly signal?: AbortSignal;
readonly progress?: ProgressReporter;
}
type EndReason = WebObservationSession["window"]["end_reason"];
type TimelineEvent = WebObservationSession["timeline"][number];
/** Observe external user actions without rejecting approved same-origin navigation. */
export const observeCdpSession = async (
context: ObservationContext,
): Promise<WebObservationSession> => {
const allowedOrigins = new Set(context.input.allowed_origins);
const initial = await authorizedFrame(context, allowedOrigins);
const initialUrl = mainFrameUrl(initial) ?? "";
const mainFrameId = frameId(initial);
if (mainFrameId === undefined)
throw new BrowserObservationError("inspect_web_page", "protocol_error");
const capture = new TimelineCapture(
context.input.max_timeline_events,
allowedOrigins,
mainFrameId,
initialUrl,
);
const removeListener = context.connection.onEvent((event) => {
if (observationEventMatches(event, context.sessionId))
capture.ingest(event);
});
const removeDisconnectListener = context.connection.onDisconnect(() =>
capture.targetTerminated(),
);
try {
await context.connection.send(
"Page.enable",
{},
context.sessionId,
context.signal,
);
await context.connection.send(
"Network.enable",
{ maxTotalBufferSize: 1_024 * 1_024 },
context.sessionId,
context.signal,
);
await context.connection.send(
"Page.setLifecycleEventsEnabled",
{ enabled: true },
context.sessionId,
context.signal,
);
const armedAt = new Date().toISOString();
await context.progress?.report({
phase: "browser_observation",
completed: 1,
total: 2,
message:
"Browser observation armed; perform the approved user action now",
});
let endReason = await waitForWindow(
context.input.observation_ms,
capture,
context.signal,
);
if (endReason === "window_elapsed")
endReason = await captureFinalUrl(context, capture, allowedOrigins);
await context.progress?.report({
phase: "browser_observation",
completed: 2,
total: 2,
message: "Browser observation window ended",
terminal: true,
});
return {
schema_version: 1,
browser: context.discovery.version,
target: {
target_id: context.target.id,
initial_url: allowedSanitizedUrl(initialUrl, allowedOrigins)?.url ?? "",
final_url: capture.finalUrl,
},
window: {
armed_at: armedAt,
ended_at: new Date().toISOString(),
requested_ms: context.input.observation_ms,
end_reason: endReason,
},
timeline: capture.timeline,
completeness: capture.completeness.snapshot(),
limitations: [
"The timeline begins after REA attaches; earlier navigation and network activity is unavailable.",
"Only navigation metadata is retained; headers, bodies, cookies, console values, and payloads are not captured by this tool.",
"An out-of-policy destination ends collection immediately and is recorded without its URL.",
],
};
} finally {
removeListener();
removeDisconnectListener();
}
};
class TimelineCapture {
readonly timeline: TimelineEvent[] = [];
readonly completeness = new CdpCaptureCompleteness(["timeline"]);
finalUrl: string | null;
#sequence = 0;
#pendingReload = false;
#endReason: EndReason | undefined;
readonly #listeners = new Set<(reason: EndReason) => void>();
constructor(
private readonly maximum: number,
private readonly allowedOrigins: ReadonlySet<string>,
private readonly mainFrameId: string,
initialUrl: string,
) {
this.finalUrl =
allowedSanitizedUrl(initialUrl, allowedOrigins)?.url ?? null;
}
ingest(event: CdpEvent): void {
if (this.#endReason !== undefined) return;
const params = recordValue(event.params);
if (params === undefined) return;
switch (event.method) {
case "Page.frameRequestedNavigation":
this.#navigationRequested(params);
break;
case "Page.frameNavigated":
this.#navigationCommitted(params);
break;
case "Page.navigatedWithinDocument":
this.#sameDocument(params);
break;
case "Network.requestWillBeSent":
this.#redirect(params);
break;
case "Network.loadingFailed":
this.#loadingFailed(params);
break;
case "Page.lifecycleEvent":
this.#lifecycle(params);
break;
case "Inspector.targetCrashed":
case "Target.detachedFromTarget":
this.targetTerminated();
break;
}
}
onEnd(listener: (reason: EndReason) => void): () => void {
if (this.#endReason !== undefined) listener(this.#endReason);
else this.#listeners.add(listener);
return () => this.#listeners.delete(listener);
}
end(reason: EndReason): void {
if (this.#endReason !== undefined) return;
this.#endReason = reason;
for (const listener of this.#listeners) listener(reason);
this.#listeners.clear();
}
targetTerminated(): void {
if (this.#endReason !== undefined) return;
this.#add("target_terminated", {}, null, null, "target_terminated");
this.end("target_terminated");
}
setFinalUrl(rawUrl: string): void {
const destination = scopedUrl(rawUrl, this.allowedOrigins);
this.finalUrl = destination.scope === "approved" ? destination.url : null;
}
#navigationRequested(params: UnknownRecord): void {
if (stringValue(params.frameId) !== this.mainFrameId) return;
const reason = safeDetail(params.reason);
this.#pendingReload = reason === "reload";
this.#add(
"navigation_requested",
params,
stringValue(params.url),
null,
reason,
);
}
#navigationCommitted(params: UnknownRecord): void {
const frame = recordValue(params.frame);
if (stringValue(frame?.id) !== this.mainFrameId) return;
const rawUrl = stringValue(frame?.url);
const destination = scopedUrl(rawUrl, this.allowedOrigins);
this.#add(
this.#pendingReload ? "same_origin_reload" : "navigation_committed",
{ ...params, frameId: frame?.id, loaderId: frame?.loaderId },
rawUrl,
null,
safeDetail(frame?.transitionType),
);
this.#pendingReload = false;
if (destination.scope === "approved") this.finalUrl = destination.url;
else {
this.finalUrl = null;
this.completeness.exclude("timeline", "out_of_target_scope");
this.end("target_left_scope");
}
}
#sameDocument(params: UnknownRecord): void {
if (stringValue(params.frameId) !== this.mainFrameId) return;
const rawUrl = stringValue(params.url);
const destination = scopedUrl(rawUrl, this.allowedOrigins);
this.#add("same_document_navigation", params, rawUrl, null, null);
if (destination.scope === "approved") this.finalUrl = destination.url;
else {
this.finalUrl = null;
this.completeness.exclude("timeline", "out_of_target_scope");
this.end("target_left_scope");
}
}
#redirect(params: UnknownRecord): void {
if (
stringValue(params.frameId) !== this.mainFrameId ||
recordValue(params.redirectResponse) === undefined
)
return;
const request = recordValue(params.request);
const rawUrl = stringValue(request?.url);
const destination = scopedUrl(rawUrl, this.allowedOrigins);
this.#add(
"redirect",
params,
rawUrl,
stringValue(params.requestId) ?? null,
null,
);
if (destination.scope !== "approved") {
this.completeness.exclude("timeline", "out_of_target_scope");
this.end("target_left_scope");
}
}
#loadingFailed(params: UnknownRecord): void {
const frame = stringValue(params.frameId);
if (frame !== undefined && frame !== this.mainFrameId) return;
this.#add(
"load_failed",
params,
null,
stringValue(params.requestId) ?? null,
safeNetworkError(params.errorText),
);
}
#lifecycle(params: UnknownRecord): void {
if (stringValue(params.frameId) !== this.mainFrameId) return;
this.#add("lifecycle", params, null, null, safeDetail(params.name));
}
#add(
type: TimelineEvent["type"],
params: UnknownRecord,
rawUrl: string | null | undefined,
requestId: string | null,
detail: string | null,
): void {
const destination =
rawUrl == null ? null : scopedUrl(rawUrl, this.allowedOrigins);
if (this.timeline.length >= this.maximum) {
this.completeness.drop("timeline_events");
return;
}
this.#sequence += 1;
this.timeline.push({
sequence: this.#sequence,
type,
timestamp: Math.max(0, numberValue(params.timestamp) ?? 0),
frame_id: boundedId(params.frameId),
loader_id: boundedId(params.loaderId),
request_id: requestId?.slice(0, 256) ?? null,
url: destination?.scope === "approved" ? destination.url : null,
destination_scope: destination?.scope ?? null,
detail,
});
}
}
const observationEventMatches = (
event: CdpEvent,
sessionId: string | undefined,
): boolean => {
if (event.method !== "Target.detachedFromTarget")
return event.sessionId === sessionId;
return (
sessionId !== undefined &&
stringValue(recordValue(event.params)?.sessionId) === sessionId
);
};
const waitForWindow = async (
durationMs: number,
capture: TimelineCapture,
signal?: AbortSignal,
): Promise<EndReason> =>
await new Promise<EndReason>((resolve, reject) => {
let settled = false;
let removeEnd = (): void => {};
const finish = (reason: EndReason): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
removeEnd();
resolve(reason);
};
const onAbort = (): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
removeEnd();
reject(new AnalysisCancelledError("observe_web_session"));
};
const timer = setTimeout(() => finish("window_elapsed"), durationMs);
removeEnd = capture.onEnd(finish);
signal?.addEventListener("abort", onAbort, { once: true });
if (signal?.aborted === true) onAbort();
});
const authorizedFrame = async (
context: ObservationContext,
allowedOrigins: ReadonlySet<string>,
): Promise<unknown> => {
for (let attempt = 0; attempt < 80; attempt += 1) {
const result = await context.connection.send(
"Page.getFrameTree",
{},
context.sessionId,
context.signal,
);
const url = mainFrameUrl(result);
if (allowedSanitizedUrl(url, allowedOrigins) !== undefined) return result;
if (isHttpUrl(url))
throw new BrowserObservationError(
"inspect_web_page",
"target_not_allowed",
);
await delayWithCancellation(25, "observe_web_session", context.signal);
}
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
};
const captureFinalUrl = async (
context: ObservationContext,
capture: TimelineCapture,
allowedOrigins: ReadonlySet<string>,
): Promise<EndReason> => {
const result = await context.connection.send(
"Page.getFrameTree",
{},
context.sessionId,
context.signal,
);
const url = mainFrameUrl(result);
if (
url !== undefined &&
allowedSanitizedUrl(url, allowedOrigins) !== undefined
) {
capture.setFinalUrl(url);
return "window_elapsed";
}
capture.finalUrl = null;
capture.completeness.exclude(
"timeline",
url === undefined ? "invalid_protocol_value" : "out_of_target_scope",
);
return "target_left_scope";
};
const frameId = (result: unknown): string | undefined =>
stringValue(
recordValue(recordValue(recordValue(result)?.frameTree)?.frame)?.id,
);
const scopedUrl = (
value: string | undefined,
allowedOrigins: ReadonlySet<string>,
): {
readonly url: string | null;
readonly scope: "approved" | "outside_policy" | "unsupported";
} => {
const allowed = allowedSanitizedUrl(value, allowedOrigins);
if (allowed !== undefined) return { url: allowed.url, scope: "approved" };
return isHttpUrl(value)
? { url: null, scope: "outside_policy" }
: { url: null, scope: "unsupported" };
};
const boundedId = (value: unknown): string | null =>
stringValue(value)?.slice(0, 256) ?? null;
const safeDetail = (value: unknown): string | null => {
const detail = stringValue(value)?.toLowerCase();
return detail !== undefined && /^[a-z0-9_.:-]{1,100}$/u.test(detail)
? detail
: null;
};
const safeNetworkError = (value: unknown): string | null => {
const error = stringValue(value);
return error !== undefined && /^net::ERR_[A-Z0-9_]{1,100}$/u.test(error)
? error
: null;
};
-31
View File
@@ -1,31 +0,0 @@
import { BrowserObservationError } from "../domain/errors.js";
import { CdpConnection } from "./CdpConnection.js";
/** Execute an optional CDP method while preserving transport and cancellation failures. */
export const optionalCdpCommand = async (
context: {
readonly connection: CdpConnection;
readonly sessionId: string | undefined;
readonly signal?: AbortSignal;
},
method: string,
parameters: Readonly<Record<string, unknown>>,
limitations: string[],
): Promise<unknown | undefined> => {
try {
return await context.connection.send(
method,
parameters,
context.sessionId,
context.signal,
);
} catch (cause: unknown) {
if (
!(cause instanceof BrowserObservationError) ||
cause.reason !== "protocol_error"
)
throw cause;
limitations.push(`${method} was unavailable from this browser target.`);
return undefined;
}
};
-625
View File
@@ -1,625 +0,0 @@
import type {
InspectWebPageInput,
WebPageInspection,
} from "../domain/browserObservation.js";
import type { ProgressReporter } from "../application/ProgressReporter.js";
import {
BrowserObservationError,
type BrowserObservationOperation,
} from "../domain/errors.js";
import {
reconcileCapturedWebScript,
stableWebResources,
stableWebScriptKey,
} from "../domain/webInventory.js";
import type { CdpEndpointDiscovery, CdpEndpointTarget } from "./CdpEndpoint.js";
import { CdpConnection } from "./CdpConnection.js";
import { CdpCaptureEvents } from "./CdpCaptureEvents.js";
import { captureStorage } from "./CdpCaptureStorage.js";
import { optionalCdpCommand } from "./CdpOptionalCommand.js";
import {
captureAccessibility,
captureDom,
captureFrames,
captureResources,
type CapturedResource,
mainFrameUrl,
} from "./CdpCaptureDocuments.js";
import {
allowedSanitizedUrl,
delayWithCancellation,
isHttpUrl,
recordValue,
recordsValue,
requiredRecord,
sourceExcluded,
sourceResult,
stringValue,
} from "./CdpCaptureValues.js";
import type { WebSourceMapRequest } from "./WebSourceMapFetcher.js";
interface CaptureContext {
readonly connection: CdpConnection;
readonly sessionId: string | undefined;
readonly operation: Extract<
BrowserObservationOperation,
"inspect_web_page" | "analyze_web_bundle"
>;
readonly discovery: CdpEndpointDiscovery;
readonly target: CdpEndpointTarget;
readonly input: InspectWebPageInput;
readonly signal?: AbortSignal;
readonly progress?: ProgressReporter;
}
interface CaptureState {
readonly context: CaptureContext;
readonly events: CdpCaptureEvents;
readonly allowedOrigins: ReadonlySet<string>;
readonly limitations: string[];
readonly startedAt: string;
}
interface CapturedSections {
readonly attachedUrl: string;
readonly frames: WebPageInspection["frames"];
readonly dom: ReturnType<typeof captureDom>;
readonly accessibility: ReturnType<typeof captureAccessibility>;
readonly scripts: WebPageInspection["scripts"];
readonly resources: WebPageInspection["resources"];
readonly workers: WebPageInspection["workers"];
readonly storage: WebPageInspection["storage"];
}
export interface CapturedPage {
readonly inspection: WebPageInspection;
readonly sourceMapRequests: readonly WebSourceMapRequest[];
}
interface CapturedScripts {
readonly inventory: WebPageInspection["scripts"];
readonly sourceMapRequests: readonly WebSourceMapRequest[];
}
/** Capture and normalize one attached page without evaluating page JavaScript. */
export const capturePage = async (
context: CaptureContext,
): Promise<CapturedPage> => {
const allowedOrigins = new Set(context.input.allowed_origins);
const events = new CdpCaptureEvents(context.input, allowedOrigins);
const removeListener = context.connection.onEvent((event) => {
if (event.sessionId !== context.sessionId) return;
events.ingest(event);
});
const state: CaptureState = {
context,
events,
allowedOrigins,
limitations: [
"Observation starts when REA attaches; prior network and console activity is unavailable.",
"Raw network headers, bodies, cookies, storage values, console objects, and WebSocket payloads are never retained; separately approved captures retain only bounded redacted text or value-free shapes.",
"Source maps are reported only as declarative URLs and are not fetched.",
"URL-less scripts and console events without an allowed source URL are excluded because their origin cannot be proven.",
],
startedAt: new Date().toISOString(),
};
try {
return await captureAuthorizedPage(state);
} finally {
removeListener();
}
};
const captureAuthorizedPage = async (
state: CaptureState,
): Promise<CapturedPage> => {
const { context, allowedOrigins, limitations } = state;
const { connection, sessionId, input, signal } = context;
await authorizeObservationWindow(state);
await captureJsonResponseBodies(state);
const frameResult = await authorizedFrameTree(context, allowedOrigins);
const attachedUrl = mainFrameUrl(frameResult) ?? "";
const frameCapture = captureFrames(
frameResult,
allowedOrigins,
input.limits.max_frames,
state.events.completeness,
);
const frames = frameCapture.items;
if (frameCapture.total > frames.length)
state.events.completeness.truncate("frames");
const captureFrame = frames[0];
if (captureFrame === undefined)
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
state.events.beginFinalCapture(captureFrame.frame_id);
const resourceCapture = captureResources(
await connection.send("Page.getResourceTree", {}, sessionId, signal),
allowedOrigins,
input.limits.max_resources,
state.events.completeness,
);
if (resourceCapture.total > resourceCapture.items.length)
state.events.completeness.truncate("resources");
const resources = stableWebResources(
resourceCapture.items.map(publicResource),
);
const dom = await capturePageDom(state);
if (dom.total > dom.nodes.length) state.events.completeness.truncate("dom");
const accessibility = await accessibilityForFrames(state, frames);
if (accessibility.total > accessibility.nodes.length)
state.events.completeness.truncate("accessibility");
const scripts = await captureScripts(
context,
state.events,
resourceCapture.items,
resources,
);
const workerCapture = await captureWorkers(
state,
new Set(frames.map((frame) => frame.frame_id)),
);
if (workerCapture.total > workerCapture.items.length)
state.events.completeness.truncate("workers");
const storageCapture = await captureStorage(
context,
frames[0]?.origin ?? new URL(attachedUrl).origin,
limitations,
);
if (storageCapture.truncated)
state.events.completeness.truncate("storage_keys");
if (!input.include_storage_keys)
state.events.completeness.exclude("storage_keys", "not_approved", null);
const completedFrameResult = await authorizedFrameTree(
context,
allowedOrigins,
);
const completedUrl = mainFrameUrl(completedFrameResult) ?? "";
if (state.events.originViolation)
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
if (state.events.navigationDuringCapture || completedUrl !== attachedUrl)
throw new BrowserObservationError("inspect_web_page", "target_changed");
await report(context.progress, 3, "Normalizing browser evidence");
return {
inspection: normalizedInspection(state, {
attachedUrl,
frames,
dom,
accessibility,
scripts: scripts.inventory,
resources,
workers: workerCapture.items,
storage: storageCapture.value,
}),
sourceMapRequests: scripts.sourceMapRequests,
};
};
const captureJsonResponseBodies = async (
state: CaptureState,
): Promise<void> => {
const requestIds = state.events.responseBodyRequestIds();
for (let index = 0; index < requestIds.length; index += 1) {
const requestId = requestIds[index];
if (requestId === undefined) continue;
const result = await optionalCdpCommand(
state.context,
"Network.getResponseBody",
{ requestId },
state.limitations,
);
if (result !== undefined) {
state.events.ingestResponseBody(requestId, result);
continue;
}
for (const remaining of requestIds.slice(index))
state.events.responseBodyUnavailable(remaining);
return;
}
};
const authorizeObservationWindow = async (
state: CaptureState,
): Promise<void> => {
const { context, allowedOrigins, events } = state;
const { connection, sessionId, signal } = context;
await report(context.progress, 1, "Enabling passive CDP domains");
await connection.send("Page.enable", {}, sessionId, signal);
const initialFrameResult = await authorizedFrameTree(context, allowedOrigins);
const mainFrame = captureFrames(initialFrameResult, allowedOrigins, 1)
.items[0];
if (mainFrame === undefined)
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
events.beginAuthorizedFrame(mainFrame.frame_id);
await enableObservationDomains(connection, sessionId, signal);
await report(context.progress, 2, "Observing page events");
await delayWithCancellation(
context.input.observation_ms,
context.operation,
signal,
);
if (events.originViolation)
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
};
const capturePageDom = async (
state: CaptureState,
): Promise<ReturnType<typeof captureDom>> => {
const { context, allowedOrigins } = state;
const result = await context.connection.send(
"DOMSnapshot.captureSnapshot",
{
computedStyles: [],
includePaintOrder: false,
includeDOMRects: false,
},
context.sessionId,
context.signal,
);
return captureDom(
result,
allowedOrigins,
context.input,
state.events.completeness,
);
};
const normalizedInspection = (
state: CaptureState,
captured: CapturedSections,
): WebPageInspection => ({
schema_version: 2,
browser: state.context.discovery.version,
target: normalizedTarget(
state.context.target,
captured.attachedUrl,
state.allowedOrigins,
),
capture_window: {
started_at: state.startedAt,
ended_at: new Date().toISOString(),
observation_ms: state.context.input.observation_ms,
},
completeness: state.events.completeness.snapshot(),
frames: [...captured.frames],
dom: { total_nodes: captured.dom.total, nodes: [...captured.dom.nodes] },
accessibility: {
total_nodes: captured.accessibility.total,
text_capture: captured.accessibility.textCapture,
nodes: [...captured.accessibility.nodes],
},
scripts: captured.scripts,
resources: [...captured.resources],
network: {
requests: [...state.events.network.values()],
websocket_events: [...state.events.websockets],
coverage_started_at: state.startedAt,
prior_activity_available: false,
},
console: {
events: [...state.events.console],
coverage_started_at: state.startedAt,
prior_activity_available: false,
},
workers: [...captured.workers],
metadata: {
responses: [...state.events.responseMetadata],
dom_urls: [...captured.dom.urls],
agent_hints: deduplicatedAgentHints([
...state.events.agentHints,
...captured.dom.agentHints,
]),
excluded_dom_urls: captured.dom.excludedUrls,
headers_allowlisted: true,
},
storage: captured.storage,
limitations: state.limitations,
});
const authorizedFrameTree = async (
context: CaptureContext,
allowedOrigins: ReadonlySet<string>,
): Promise<unknown> => {
for (let attempt = 0; attempt < 80; attempt += 1) {
const result = await context.connection.send(
"Page.getFrameTree",
{},
context.sessionId,
context.signal,
);
const currentUrl = mainFrameUrl(result);
if (allowedSanitizedUrl(currentUrl, allowedOrigins) !== undefined)
return result;
if (isHttpUrl(currentUrl))
throw new BrowserObservationError(
"inspect_web_page",
"target_not_allowed",
);
await delayWithCancellation(25, context.operation, context.signal);
}
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
};
const enableObservationDomains = async (
connection: CdpConnection,
sessionId: string | undefined,
signal?: AbortSignal,
): Promise<void> => {
await connection.send("Runtime.enable", {}, sessionId, signal);
await connection.send(
"Debugger.enable",
{ maxScriptsCacheSize: 4 * 1_024 * 1_024 },
sessionId,
signal,
);
await connection.send(
"Network.enable",
{
maxTotalBufferSize: 4 * 1_024 * 1_024,
maxResourceBufferSize: 1_024 * 1_024,
},
sessionId,
signal,
);
};
const accessibilityForFrames = async (
state: CaptureState,
frames: readonly { readonly frame_id: string }[],
): Promise<ReturnType<typeof captureAccessibility>> => {
const { context, limitations, events } = state;
const results: unknown[] = [];
let unavailable = false;
for (const frame of frames) {
const result = await optionalCdpCommand(
context,
"Accessibility.getFullAXTree",
{ depth: 32, frameId: frame.frame_id },
limitations,
);
if (result === undefined) unavailable = true;
else results.push(result);
}
if (unavailable) events.completeness.unavailable("accessibility");
const capture = captureAccessibility(
results,
context.input.limits.max_ax_nodes,
{
includeText: context.input.include_accessibility_text,
maximumFieldBytes: context.input.limits.max_ax_text_field_bytes,
maximumTotalBytes: context.input.limits.max_total_ax_text_bytes,
...(results.length === 0 && unavailable ? { unavailable: true } : {}),
},
);
if (!context.input.include_accessibility_text)
events.completeness.exclude(
"accessibility",
"not_approved",
capture.textCapture.excluded_fields,
);
if (capture.textCapture.status === "truncated")
events.completeness.truncate("accessibility");
return capture;
};
const captureScripts = async (
context: CaptureContext,
events: CdpCaptureEvents,
rawResources: readonly CapturedResource[],
resources: WebPageInspection["resources"],
): Promise<CapturedScripts> => {
let totalSourceBytes = 0;
const drafts: {
readonly item: Omit<
WebPageInspection["scripts"]["items"][number],
"script_key"
>;
readonly sourceMapRawUrl: string | null;
}[] = [];
for (const script of events.scripts.values()) {
let source: WebPageInspection["scripts"]["items"][number]["source"] =
sourceExcluded("source capture was not approved");
if (context.input.include_script_sources) {
if (script.length > context.input.limits.max_script_source_bytes) {
source = sourceExcluded(
"declared script length exceeds per-script limit",
);
events.completeness.truncate("script_sources");
} else if (
totalSourceBytes + script.length >
context.input.limits.max_total_script_source_bytes
) {
source = sourceExcluded("total script source limit reached");
events.completeness.truncate("script_sources");
} else {
const result = requiredRecord(
await context.connection.send(
"Debugger.getScriptSource",
{ scriptId: script.scriptId },
context.sessionId,
context.signal,
),
);
const content = stringValue(result.scriptSource) ?? "";
const bytes = Buffer.byteLength(content);
if (
bytes > context.input.limits.max_script_source_bytes ||
totalSourceBytes + bytes >
context.input.limits.max_total_script_source_bytes
) {
source = sourceExcluded(
"actual source exceeds configured byte limits",
);
events.completeness.truncate("script_sources");
} else {
source = sourceResult(content);
totalSourceBytes += bytes;
}
}
}
const inventoryScript = {
url: script.url,
cdp_hash: script.hash,
length: script.length,
is_module: script.isModule,
language: script.language,
source_map_url: script.sourceMapUrl,
};
drafts.push({
item: {
...inventoryScript,
origin: script.origin,
resource_reconciliation: reconcileCapturedWebScript(
{ ...inventoryScript, rawUrl: script.rawUrl },
rawResources,
resources,
),
source,
},
sourceMapRawUrl: script.sourceMapRawUrl,
});
}
if (!context.input.include_script_sources)
events.completeness.exclude(
"script_sources",
"not_approved",
events.scripts.size,
);
const keyed = drafts.map((draft) => ({
...draft,
base: stableWebScriptKey(draft.item),
}));
keyed.sort(
(left, right) =>
left.base.localeCompare(right.base) ||
sourceDigest(left.item.source).localeCompare(
sourceDigest(right.item.source),
),
);
const totals = new Map<string, number>();
for (const { base } of keyed) totals.set(base, (totals.get(base) ?? 0) + 1);
const seen = new Map<string, number>();
const sourceMapRequests: WebSourceMapRequest[] = [];
const items = keyed.map(({ base, item, sourceMapRawUrl }) => {
const occurrence = (seen.get(base) ?? 0) + 1;
seen.set(base, occurrence);
const scriptKey =
totals.get(base) === 1 ? base : `${base}_${String(occurrence)}`;
if (item.source_map_url !== null && sourceMapRawUrl !== null)
sourceMapRequests.push({
scriptKey,
declaredUrl: item.source_map_url,
fetchUrl: sourceMapRawUrl,
});
return { script_key: scriptKey, ...item };
});
return {
inventory: { total: events.scripts.size, items },
sourceMapRequests,
};
};
const sourceDigest = (
source: WebPageInspection["scripts"]["items"][number]["source"],
): string => (source.included ? source.artifact.sha256 : source.reason);
const publicResource = ({ rawUrl: _rawUrl, ...resource }: CapturedResource) =>
resource;
const deduplicatedAgentHints = (
hints: WebPageInspection["metadata"]["agent_hints"],
): WebPageInspection["metadata"]["agent_hints"] => {
const seen = new Set<string>();
return hints.filter((hint) => {
const key = `${hint.mechanism}\0${hint.declaration}\0${hint.url ?? ""}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
};
const captureWorkers = async (
state: CaptureState,
frameIds: ReadonlySet<string>,
): Promise<{
readonly total: number;
readonly items: WebPageInspection["workers"];
}> => {
const { context, allowedOrigins, limitations, events } = state;
const completeness = events.completeness;
const result = await optionalCdpCommand(
{ ...context, sessionId: undefined },
"Target.getTargets",
{},
limitations,
);
const items: WebPageInspection["workers"] = [];
let total = 0;
if (result === undefined) completeness.unavailable("workers");
for (const target of recordsValue(recordValue(result)?.targetInfos)) {
const type = stringValue(target.type) ?? "";
const url = allowedSanitizedUrl(target.url, allowedOrigins);
const relatedToPage =
stringValue(target.openerId) === context.target.id ||
(stringValue(target.parentFrameId) !== undefined &&
frameIds.has(stringValue(target.parentFrameId) ?? ""));
if (!type.includes("worker")) continue;
if (!relatedToPage) {
completeness.exclude("workers", "out_of_target_scope");
continue;
}
if (url === undefined) {
const rawUrl = stringValue(target.url);
completeness.exclude(
"workers",
rawUrl === undefined || rawUrl === ""
? "unattributed_origin"
: isHttpUrl(rawUrl)
? "disallowed_origin"
: "unsupported_url",
);
continue;
}
total += 1;
if (items.length >= context.input.limits.max_workers) continue;
items.push({
target_id: (stringValue(target.targetId) ?? "").slice(0, 256),
type: type.slice(0, 100),
url: url.url,
origin: url.origin,
attached: target.attached === true,
});
}
return {
total,
items,
};
};
const normalizedTarget = (
target: CdpEndpointTarget,
currentUrl: string,
allowedOrigins: ReadonlySet<string>,
): WebPageInspection["target"] => {
const url = allowedSanitizedUrl(currentUrl, allowedOrigins);
return {
target_id: target.id,
type: target.type,
title: target.title.slice(0, 16_384),
url: url?.url ?? "[unsupported-url]",
origin: url?.origin ?? "",
attached: target.attached,
};
};
const report = async (
progress: ProgressReporter | undefined,
completed: number,
message: string,
): Promise<void> =>
await progress?.report({
phase: "browser_observation",
completed,
total: 3,
message,
...(completed === 3 ? { terminal: true } : {}),
});
-277
View File
@@ -1,277 +0,0 @@
import type { WebPageInspection } from "../domain/browserObservation.js";
import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
import {
numberValue,
stringValue,
type UnknownRecord,
} from "./CdpCaptureValues.js";
type ResponseMetadata = WebPageInspection["metadata"]["responses"][number];
type LinkMetadata = ResponseMetadata["links"][number];
type AgentHint = WebPageInspection["metadata"]["agent_hints"][number];
/** Normalize an allowlisted subset of response metadata and discard raw headers. */
export const safeResponseMetadata = (
requestId: string,
url: string,
response: UnknownRecord,
allowedOrigins: ReadonlySet<string>,
): {
readonly response: ResponseMetadata;
readonly agentHints: AgentHint[];
} => {
const headers = normalizedHeaders(response.headers);
const links = parseLinks(headers.get("link"), url, allowedOrigins);
const csp = parseCsp(
headers.get("content-security-policy"),
url,
allowedOrigins,
);
const agentHints = [
...links.flatMap((link) =>
link.rel.some(isAgentRel)
? [agentHint("link_rel", link.rel.join(" "), link.href)]
: [],
),
...agentHeaderNames.flatMap((name) =>
headers.has(name) ? [agentHint("response_header", name, null)] : [],
),
...wellKnownHint(url),
];
return {
response: {
request_id: requestId,
url,
mime_type: boundedHeader(stringValue(response.mimeType)),
content_length: nonnegativeInteger(headers.get("content-length")),
content_encoding: boundedHeader(headers.get("content-encoding")),
csp,
links,
policies: {
coop: policyToken(headers.get("cross-origin-opener-policy")),
coep: policyToken(headers.get("cross-origin-embedder-policy")),
corp: policyToken(headers.get("cross-origin-resource-policy")),
referrer_policy: policyToken(headers.get("referrer-policy")),
x_content_type_options: policyToken(
headers.get("x-content-type-options"),
),
permissions_policy_features: permissionFeatures(
headers.get("permissions-policy"),
),
},
},
agentHints,
};
};
const normalizedHeaders = (value: unknown): ReadonlyMap<string, string> => {
if (typeof value !== "object" || value === null || Array.isArray(value))
return new Map();
const headers = new Map<string, string>();
for (const [name, raw] of Object.entries(value).slice(0, 500)) {
const normalized = name.toLowerCase().slice(0, 256);
const header =
typeof raw === "string"
? raw
: typeof raw === "number" && Number.isFinite(raw)
? String(raw)
: undefined;
if (header !== undefined) headers.set(normalized, header.slice(0, 65_536));
}
return headers;
};
const parseCsp = (
value: string | undefined,
baseUrl: string,
allowedOrigins: ReadonlySet<string>,
): ResponseMetadata["csp"] => {
const directives: ResponseMetadata["csp"]["directives"] = [];
let nonceCount = 0;
let hashCount = 0;
for (const rawDirective of (value ?? "").split(";").slice(0, 100)) {
const [rawName, ...tokens] = rawDirective.trim().split(/\s+/u);
const name = (rawName ?? "").toLowerCase();
if (!/^[a-z][a-z0-9-]{0,99}$/u.test(name)) continue;
const sources: ResponseMetadata["csp"]["directives"][number]["sources"] =
[];
for (const token of tokens.slice(0, 100)) {
const lower = token.toLowerCase();
if (lower.startsWith("'nonce-")) {
nonceCount += 1;
continue;
}
if (
lower.startsWith("'sha256-") ||
lower.startsWith("'sha384-") ||
lower.startsWith("'sha512-")
) {
hashCount += 1;
continue;
}
sources.push(cspSource(token, baseUrl, allowedOrigins));
}
directives.push({ name, sources });
}
return { directives, nonce_count: nonceCount, hash_count: hashCount };
};
const cspSource = (
token: string,
baseUrl: string,
allowedOrigins: ReadonlySet<string>,
): ResponseMetadata["csp"]["directives"][number]["sources"][number] => {
const lower = token.toLowerCase();
if (/^'[a-z0-9-]+'$/u.test(lower))
return { kind: "keyword", value: lower.slice(0, 100) };
if (/^[a-z][a-z0-9+.-]*:$/u.test(lower))
return { kind: "scheme", value: lower.slice(0, 100) };
try {
const parsed = new URL(token, baseUrl);
return allowedOrigins.has(parsed.origin)
? { kind: "approved_origin", value: parsed.origin }
: { kind: "external_origin", value: null };
} catch {
return { kind: "other", value: null };
}
};
const parseLinks = (
value: string | undefined,
baseUrl: string,
allowedOrigins: ReadonlySet<string>,
): LinkMetadata[] => {
const links: LinkMetadata[] = [];
for (const entry of splitLinkHeader(value ?? "").slice(0, 100)) {
const match = /^\s*<([^>]{1,4096})>(.*)$/u.exec(entry);
if (match === null) continue;
const parameters = linkParameters(match[2] ?? "");
const destination = safeDestination(
match[1] ?? "",
baseUrl,
allowedOrigins,
);
links.push({
href: destination.url,
destination_scope: destination.scope,
rel: (parameters.get("rel") ?? "")
.toLowerCase()
.split(/\s+/u)
.filter(Boolean)
.slice(0, 32),
as: boundedHeader(parameters.get("as")),
type: boundedHeader(parameters.get("type")),
crossorigin: boundedHeader(parameters.get("crossorigin")),
});
}
return links;
};
const splitLinkHeader = (value: string): string[] => {
const entries: string[] = [];
let start = 0;
let quoted = false;
for (let index = 0; index < value.length; index += 1) {
const character = value[index];
if (character === '"' && value[index - 1] !== "\\") quoted = !quoted;
if (character !== "," || quoted) continue;
entries.push(value.slice(start, index));
start = index + 1;
}
entries.push(value.slice(start));
return entries;
};
const linkParameters = (value: string): ReadonlyMap<string, string> => {
const parameters = new Map<string, string>();
for (const raw of value.split(";").slice(1, 50)) {
const separator = raw.indexOf("=");
const name = (separator < 0 ? raw : raw.slice(0, separator))
.trim()
.toLowerCase();
if (!/^[a-z][a-z0-9-]{0,99}$/u.test(name)) continue;
const parameter = separator < 0 ? "" : raw.slice(separator + 1).trim();
parameters.set(name, unquote(parameter).slice(0, 1_024));
}
return parameters;
};
const safeDestination = (
value: string,
baseUrl: string,
allowedOrigins: ReadonlySet<string>,
): {
readonly url: string | null;
readonly scope: LinkMetadata["destination_scope"];
} => {
try {
const parsed = new URL(value, baseUrl);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
return { url: null, scope: "unsupported" };
if (!allowedOrigins.has(parsed.origin))
return { url: null, scope: "outside_policy" };
return { url: sanitizeBrowserUrl(parsed.href).url, scope: "approved" };
} catch {
return { url: null, scope: "unsupported" };
}
};
const permissionFeatures = (value: string | undefined): string[] =>
[
...new Set(
(value ?? "")
.split(",")
.map((entry) => entry.split("=", 1)[0]?.trim().toLowerCase() ?? "")
.filter((feature) => /^[a-z][a-z0-9-]{0,99}$/u.test(feature)),
),
]
.sort()
.slice(0, 200);
const wellKnownHint = (url: string): AgentHint[] => {
try {
const path = new URL(url).pathname.toLowerCase();
return wellKnownAgentPaths.has(path)
? [agentHint("well_known_resource", path, sanitizeBrowserUrl(url).url)]
: [];
} catch {
return [];
}
};
const agentHint = (
mechanism: AgentHint["mechanism"],
declaration: string,
url: string | null,
): AgentHint => ({
mechanism,
declaration: declaration.slice(0, 256),
url,
trust: "page-declared-untrusted",
});
const isAgentRel = (value: string): boolean =>
["mcp", "model-context", "ai-plugin", "service-desc"].includes(value);
const policyToken = (value: string | undefined): string | null => {
const token = (value ?? "").trim().toLowerCase();
return /^[a-z][a-z0-9_.-]{0,99}$/u.test(token) ? token : null;
};
const nonnegativeInteger = (value: string | undefined): number | null => {
const number = numberValue(value === undefined ? undefined : Number(value));
return number === undefined ? null : Math.max(0, Math.trunc(number));
};
const boundedHeader = (value: string | undefined): string | null =>
value === undefined ? null : value.trim().toLowerCase().slice(0, 256);
const unquote = (value: string): string =>
value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value;
const agentHeaderNames = ["x-model-context", "x-webmcp", "mcp-server"] as const;
const wellKnownAgentPaths: ReadonlySet<string> = new Set([
"/.well-known/ai-plugin.json",
"/.well-known/mcp",
"/.well-known/model-context",
]);
-164
View File
@@ -1,164 +0,0 @@
import type { ProgressReporter } from "../application/ProgressReporter.js";
import type {
CaptureWebScreenshotInput,
WebScreenshot,
} from "../domain/webScreenshot.js";
import { createWebScreenshotArtifact } from "../domain/webScreenshot.js";
import { BrowserObservationError } from "../domain/errors.js";
import type { CdpEndpointDiscovery, CdpEndpointTarget } from "./CdpEndpoint.js";
import type { CdpConnection, CdpEvent } from "./CdpConnection.js";
import {
allowedSanitizedUrl,
recordValue,
stringValue,
} from "./CdpCaptureValues.js";
import { mainFrameUrl } from "./CdpCaptureDocuments.js";
import { captureFrames } from "./CdpCaptureDocuments.js";
import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
import { decodeCanonicalBase64 } from "../domain/webScreenshot.js";
interface ScreenshotContext {
readonly connection: CdpConnection;
readonly sessionId: string | undefined;
readonly discovery: CdpEndpointDiscovery;
readonly target: CdpEndpointTarget;
readonly input: CaptureWebScreenshotInput;
readonly signal?: AbortSignal;
readonly progress?: ProgressReporter;
}
/** Capture the current viewport without navigation, evaluation, or page mutation. */
export const captureCdpScreenshot = async (
context: ScreenshotContext,
): Promise<WebScreenshot> => {
const origins = new Set(context.input.allowed_origins);
await context.progress?.report({
phase: "browser_observation",
completed: 1,
total: 2,
message: "Authorizing current screenshot frame",
});
await context.connection.send(
"Page.enable",
{},
context.sessionId,
context.signal,
);
const before = await context.connection.send(
"Page.getFrameTree",
{},
context.sessionId,
context.signal,
);
const beforeUrl = mainFrameUrl(before);
const authorized = allowedSanitizedUrl(beforeUrl, origins);
const mainFrame = captureFrames(before, origins, 1).items[0];
if (authorized === undefined || mainFrame === undefined)
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
const navigation = { changed: false, leftScope: false };
const removeListener = context.connection.onEvent((event) => {
if (event.sessionId !== context.sessionId) return;
observeScreenshotNavigation(event, mainFrame.frame_id, origins, navigation);
});
let result: ReturnType<typeof recordValue>;
let after: unknown;
try {
result = recordValue(
await context.connection.send(
"Page.captureScreenshot",
{ format: "png", fromSurface: true, captureBeyondViewport: false },
context.sessionId,
context.signal,
),
);
after = await context.connection.send(
"Page.getFrameTree",
{},
context.sessionId,
context.signal,
);
} finally {
removeListener();
}
const encoded = stringValue(result?.data);
const bytes =
encoded === undefined ? undefined : decodeCanonicalBase64(encoded);
if (
bytes === undefined ||
bytes.byteLength > context.input.maximum_image_bytes
)
throw new BrowserObservationError("inspect_web_page", "payload_limit");
const dimensions = pngDimensions(bytes);
if (navigation.leftScope)
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
const afterUrl = mainFrameUrl(after);
if (
navigation.changed ||
afterUrl !== beforeUrl ||
allowedSanitizedUrl(afterUrl, origins) === undefined
)
throw new BrowserObservationError("inspect_web_page", "target_changed");
await context.progress?.report({
phase: "browser_observation",
completed: 2,
total: 2,
message: "Screenshot capture complete",
terminal: true,
});
return {
schema_version: 1,
browser: context.discovery.version,
target: {
target_id: context.target.id,
url: authorized.url,
origin: authorized.origin ?? "",
},
captured_at: new Date().toISOString(),
viewport: dimensions,
artifact: createWebScreenshotArtifact(bytes),
completeness: new CdpCaptureCompleteness().snapshot(),
limitations: [
"The screenshot contains the visible viewport and may include sensitive on-screen content; capture requires separate approval.",
"REA does not scroll, evaluate JavaScript, or capture beyond the current viewport.",
],
};
};
const observeScreenshotNavigation = (
event: CdpEvent,
mainFrameId: string,
origins: ReadonlySet<string>,
state: { changed: boolean; leftScope: boolean },
): void => {
const params = recordValue(event.params);
if (params === undefined) return;
const frame =
event.method === "Page.frameNavigated" ? recordValue(params.frame) : params;
if (
(event.method !== "Page.frameNavigated" &&
event.method !== "Page.navigatedWithinDocument") ||
(stringValue(frame?.id) ?? stringValue(frame?.frameId)) !== mainFrameId
)
return;
state.changed = true;
if (allowedSanitizedUrl(frame?.url, origins) === undefined)
state.leftScope = true;
};
const pngDimensions = (
bytes: Buffer,
): { readonly width: number; readonly height: number } => {
if (
bytes.byteLength < 24 ||
!bytes.subarray(0, 8).equals(PNG_SIGNATURE) ||
bytes.subarray(12, 16).toString("ascii") !== "IHDR"
)
throw new BrowserObservationError("inspect_web_page", "protocol_error");
const width = bytes.readUInt32BE(16);
const height = bytes.readUInt32BE(20);
if (width === 0 || height === 0 || width * height > 32_000_000)
throw new BrowserObservationError("inspect_web_page", "payload_limit");
return { width, height };
};
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);

Some files were not shown because too many files have changed in this diff Show More