Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f6f1600c5 | ||
|
|
092a303b0f |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "1.4.0"
|
||||
".": "1.3.0"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,32 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
+4
-4
@@ -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, 2 browser, and 18 session tools (70 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:
|
||||
|
||||
@@ -31,8 +31,8 @@ lint:dead` rejects unused files, exports, and dependencies.
|
||||
Set `REA_LOG_LEVEL` to `trace`, `debug`, `info`, `warn`, `error`, `fatal`, or
|
||||
`silent` to control structured JSON diagnostics. MCP mode defaults to `info` and
|
||||
always writes logs to stderr so the stdio protocol remains intact. One-shot CLI
|
||||
logging is opt-in and writes to stderr when a level is configured, preserving
|
||||
machine-readable command output. Request arguments, bridge
|
||||
logging is opt-in and writes to stdout when a level is configured, preserving
|
||||
machine-readable command output by default. Request arguments, bridge
|
||||
authentication tokens, and environment data are redacted.
|
||||
|
||||
Changes that claim real Hopper behavior must also be tested against two distinct binaries:
|
||||
@@ -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 70 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
|
||||
|
||||
@@ -10,20 +10,16 @@
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](#70-tools-for-investigation)
|
||||
[](#68-tools-for-investigation)
|
||||
[](https://nodejs.org/)
|
||||
[](LICENSE)
|
||||
|
||||
[Quick start](#quick-start) · [Current status](#current-status) · [Investigation model](#the-investigation-model) · [70 tools](#70-tools-for-investigation) · [Roadmap](#roadmap) · [How it works](#how-it-works)
|
||||
[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>
|
||||
|
||||
---
|
||||
@@ -294,16 +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.
|
||||
|
||||
## 70 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 | 2 | exact-origin CDP page discovery and passive DOM, accessibility, script, resource, network, console, worker, and storage 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.
|
||||
|
||||
@@ -312,7 +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, list only pages on approved exact origins, and capture bounded passive web evidence without navigation or JavaScript evaluation.
|
||||
- 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.
|
||||
@@ -333,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
|
||||
```
|
||||
|
||||
`list_browser_targets` and `inspect_web_page` expose the same Evidence v2 contracts over MCP. Inspection is passive: REA does not evaluate page JavaScript, navigate, click, close the page, or close the browser. It redacts query values and never retains headers, bodies, cookies, storage values, console argument values, or WebSocket payloads. Existing activity before attach is explicitly unavailable. See [Website observation with CDP](docs/browser-observation.md) for browser startup, schemas, limits, and the threat model.
|
||||
|
||||
## 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 observation with approved interaction, screenshots, Electron IPC, route discovery, controlled replay, and visual or structural differences.
|
||||
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.
|
||||
@@ -398,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
|
||||
@@ -431,19 +409,6 @@ Run `npx -y rea-agents --help` for direct decompilation, bounded search and
|
||||
other options. `analyze` and `inspect` share the same overview workflow;
|
||||
`function`, `xrefs`, and `trace` return the same Evidence v2 envelopes as MCP.
|
||||
|
||||
CLI exit status is independent of output format, full output, filtering, and
|
||||
token limits:
|
||||
|
||||
| Status | Meaning |
|
||||
| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `0` | The requested operation completed truthfully. Explicit unknown, bounded, or truncated evidence remains successful when the operation completed under its declared contract. |
|
||||
| `1` | Argument validation, approval or policy, provider or analysis, integrity, cancellation or timeout, serialization, or output failed. Structured output retains the typed category and safe diagnostic details when available. |
|
||||
|
||||
Machine-readable failures remain on stdout in the requested format; incidental
|
||||
diagnostics and progress use stderr. Signal termination retains the conventional
|
||||
status supplied by the shell and runtime. Shell pipelines should enable
|
||||
`pipefail` when the REA status must propagate through later commands.
|
||||
|
||||
Or install the `rea` command globally:
|
||||
|
||||
```bash
|
||||
@@ -455,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
-11
@@ -10,11 +10,11 @@
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](#منصة-من-70-أداة)
|
||||
[](#منصة-من-68-أداة)
|
||||
[](https://nodejs.org/)
|
||||
[](LICENSE)
|
||||
|
||||
[البدء السريع](#البدء-السريع) · [من الملف التنفيذي إلى السلوك](#من-الملف-التنفيذي-إلى-السلوك) · [70 أداة](#منصة-من-70-أداة) · [كيف يعمل](#كيف-يعمل) · [الأسئلة الشائعة](#الأسئلة-الشائعة)
|
||||
[البدء السريع](#البدء-السريع) · [من الملف التنفيذي إلى السلوك](#من-الملف-التنفيذي-إلى-السلوك) · [68 أداة](#منصة-من-68-أداة) · [كيف يعمل](#كيف-يعمل) · [الأسئلة الشائعة](#الأسئلة-الشائعة)
|
||||
|
||||
<br />
|
||||
|
||||
@@ -155,16 +155,15 @@ rea uninstall --purge-data
|
||||
|
||||
افحص التحقق من الإدخال، والتشفير، والصلاحيات، والتخزين، ومسارات الأخطاء من دون رفع الملف التنفيذي إلى خدمة بعيدة.
|
||||
|
||||
## منصة من 70 أداة
|
||||
## منصة من 68 أداة
|
||||
|
||||
| عائلة الأدوات | العدد | أمثلة |
|
||||
| --------------------- | ----: | ---------------------------------------------------------------------------------------- |
|
||||
| فحص الملفات التنفيذية | 33 | الدوال، والشيفرة شبه المصدرية، والتعليمات، والسلاسل، والأسماء، والمراجع، والتعليقات |
|
||||
| التحليل المركب | 10 | النظرة العامة، وفك الترجمة الدفعي، ومخططات الاستدعاء، والمراجع، واكتشاف الأنواع |
|
||||
| أدوات macOS الأصلية | 5 | بيانات Mach-O والتوقيعات وملفات plist والمعماريات وفك رموز Swift دون تشغيل Hopper |
|
||||
| رسم القطع الأثرية | 2 | جرد حتمي للدلائل وZIP/APK/IPA وASAR واستخراج معاملاتي محدد صراحة |
|
||||
| مراقبة المتصفح | 2 | اكتشاف صفحات CDP ضمن أصول دقيقة وفحص سلبي لـ DOM وإتاحة الوصول والبرامج والشبكة والتخزين |
|
||||
| جلسة الملف التنفيذي | 18 | فتح الأهداف وحزم الأدلة ومقارنة العمليات والقطع والدوال وسجل المجهولات المتبقية |
|
||||
| عائلة الأدوات | العدد | أمثلة |
|
||||
| --------------------- | ----: | ----------------------------------------------------------------------------------- |
|
||||
| فحص الملفات التنفيذية | 33 | الدوال، والشيفرة شبه المصدرية، والتعليمات، والسلاسل، والأسماء، والمراجع، والتعليقات |
|
||||
| التحليل المركب | 10 | النظرة العامة، وفك الترجمة الدفعي، ومخططات الاستدعاء، والمراجع، واكتشاف الأنواع |
|
||||
| أدوات macOS الأصلية | 5 | بيانات Mach-O والتوقيعات وملفات plist والمعماريات وفك رموز Swift دون تشغيل Hopper |
|
||||
| رسم القطع الأثرية | 2 | جرد حتمي للدلائل وZIP/APK/IPA وASAR واستخراج معاملاتي محدد صراحة |
|
||||
| جلسة الملف التنفيذي | 18 | فتح الأهداف وحزم الأدلة ومقارنة العمليات والقطع والدوال وسجل المجهولات المتبقية |
|
||||
|
||||
## استخدام REA مع وكلاء برمجة آخرين
|
||||
|
||||
|
||||
+3
-4
@@ -10,11 +10,11 @@
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](#70-ツールのワークベンチ)
|
||||
[](#68-ツールのワークベンチ)
|
||||
[](https://nodejs.org/)
|
||||
[](LICENSE)
|
||||
|
||||
[クイックスタート](#クイックスタート) · [バイナリから動作へ](#バイナリから動作へ) · [70 ツール](#70-ツールのワークベンチ) · [仕組み](#仕組み) · [FAQ](#faq)
|
||||
[クイックスタート](#クイックスタート) · [バイナリから動作へ](#バイナリから動作へ) · [68 ツール](#68-ツールのワークベンチ) · [仕組み](#仕組み) · [FAQ](#faq)
|
||||
|
||||
<br />
|
||||
|
||||
@@ -145,7 +145,7 @@ REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェ
|
||||
- Swift / Objective-C のメタデータを解析する。
|
||||
- Hopper に名前、コメント、ブックマークを残し、人間とエージェントの調査を共有する。
|
||||
|
||||
## 70 ツールのワークベンチ
|
||||
## 68 ツールのワークベンチ
|
||||
|
||||
| ツール群 | 数 | 例 |
|
||||
| ---------------------- | --: | ------------------------------------------------------------------------------------------------------------------- |
|
||||
@@ -153,7 +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 の決定的一覧と明示選択されたトランザクション抽出 |
|
||||
| ブラウザ観察 | 2 | 完全一致 origin に限定した CDP ページ検出と受動的な DOM、アクセシビリティ、スクリプト、通信、保存領域の調査 |
|
||||
| バイナリセッション | 18 | `open_binary`、`binary_session`、証拠バンドル、プロセス/成果物/関数比較、残余未知レジストリ |
|
||||
|
||||
## 他のコーディングエージェントで使う
|
||||
|
||||
+3
-4
@@ -10,11 +10,11 @@
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](#70개-도구로-구성된-워크벤치)
|
||||
[](#68개-도구로-구성된-워크벤치)
|
||||
[](https://nodejs.org/)
|
||||
[](LICENSE)
|
||||
|
||||
[빠른 시작](#빠른-시작) · [바이너리에서 동작까지](#바이너리에서-동작까지) · [70개 도구](#70개-도구로-구성된-워크벤치) · [작동 방식](#작동-방식) · [FAQ](#faq)
|
||||
[빠른 시작](#빠른-시작) · [바이너리에서 동작까지](#바이너리에서-동작까지) · [68개 도구](#68개-도구로-구성된-워크벤치) · [작동 방식](#작동-방식) · [FAQ](#faq)
|
||||
|
||||
<br />
|
||||
|
||||
@@ -145,7 +145,7 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이
|
||||
- Swift 및 Objective-C 메타데이터를 분석합니다.
|
||||
- Hopper에 이름, 주석, 북마크를 남겨 사람과 에이전트의 분석을 연결합니다.
|
||||
|
||||
## 70개 도구로 구성된 워크벤치
|
||||
## 68개 도구로 구성된 워크벤치
|
||||
|
||||
| 도구 그룹 | 수 | 예시 |
|
||||
| ------------------- | --: | -------------------------------------------------------------------------------------------------------------------- |
|
||||
@@ -153,7 +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 결정적 인벤토리와 명시적으로 선택한 트랜잭션 추출 |
|
||||
| 브라우저 관찰 | 2 | 정확한 origin 범위의 CDP 페이지 검색과 수동 DOM, 접근성, 스크립트, 네트워크, 콘솔 및 저장소 조사 |
|
||||
| 바이너리 세션 | 18 | `open_binary`, `binary_session`, 증거 번들, 프로세스·아티팩트·함수 비교, 잔여 미지수 레지스트리 |
|
||||
|
||||
## 다른 코딩 에이전트에서 사용하기
|
||||
|
||||
+3
-4
@@ -10,11 +10,11 @@
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](#70-个工具组成的工作台)
|
||||
[](#68-个工具组成的工作台)
|
||||
[](https://nodejs.org/)
|
||||
[](LICENSE)
|
||||
|
||||
[快速开始](#快速开始) · [从二进制到行为](#从二进制到行为) · [70 个工具](#70-个工具组成的工作台) · [工作原理](#工作原理) · [常见问题](#常见问题)
|
||||
[快速开始](#快速开始) · [从二进制到行为](#从二进制到行为) · [68 个工具](#68-个工具组成的工作台) · [工作原理](#工作原理) · [常见问题](#常见问题)
|
||||
|
||||
<br />
|
||||
|
||||
@@ -145,7 +145,7 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
|
||||
- 分析 Swift 和 Objective-C 元数据。
|
||||
- 在 Hopper 中留下名称、注释与书签,使人与智能体的分析互相增强。
|
||||
|
||||
## 70 个工具组成的工作台
|
||||
## 68 个工具组成的工作台
|
||||
|
||||
| 工具类别 | 数量 | 示例 |
|
||||
| -------------- | ---: | -------------------------------------------------------------------------------------------------------------------- |
|
||||
@@ -153,7 +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 清单,以及显式选择的事务式提取 |
|
||||
| 浏览器观察 | 2 | 基于精确来源范围的 CDP 页面发现,以及被动 DOM、无障碍树、脚本、网络、控制台与存储检查 |
|
||||
| 二进制会话 | 18 | `open_binary`、`binary_session`、证据包、进程、制品与函数比较、残余未知项注册表 |
|
||||
|
||||
## 与其他编程智能体一起使用
|
||||
|
||||
@@ -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)}}
|
||||
|
||||
@@ -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/>70 analysis, browser, 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 |
@@ -1,121 +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, attach-window network and console metadata, WebSocket frame sizes, workers, quota, and optionally storage key names or script sources.
|
||||
- Both surfaces return the same provider-neutral normalized results and 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.
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
Script content and storage key names require separate opt-ins:
|
||||
|
||||
```bash
|
||||
rea inspect-web-page http://127.0.0.1:9222 TARGET_ID \
|
||||
--approved \
|
||||
--include-script-sources \
|
||||
--include-storage-keys \
|
||||
--json
|
||||
```
|
||||
|
||||
Script content may contain application secrets and becomes part of the returned Evidence. Storage values remain redacted even when key-name capture is approved.
|
||||
|
||||
## 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_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.
|
||||
- Network observations retain method, status, MIME type, size, type, and redacted URLs, but not request/response headers, bodies, post data, cookies, or response content.
|
||||
- Console observations with an approved stack source retain call type, argument types, timestamp, and redacted source location, but not argument values or descriptions. Events without a source URL are excluded because their origin cannot be established.
|
||||
- WebSocket observations retain direction, opcode, and payload byte length, but not payload content.
|
||||
- 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. URL-less scripts are excluded because their origin cannot be established. Source content is omitted unless explicitly requested and remains 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`.
|
||||
|
||||
A bounded result reports `completeness.status: "truncated"`, named truncated sections, and dropped event 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 reported only as redacted declarative URLs and are never fetched.
|
||||
|
||||
## Non-goals and threat model
|
||||
|
||||
The first browser provider deliberately does not expose generic CDP commands, `Runtime.evaluate`, navigation, input, downloads, screenshots, page closure, or browser closure. 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.
|
||||
@@ -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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1,7 +1,7 @@
|
||||
# Guided MCP prompts and completion
|
||||
|
||||
REA exposes six provider-neutral investigation workflows through the MCP
|
||||
`prompts` capability. They complement the 70 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
|
||||
|
||||
Generated
+10
-25
@@ -1,19 +1,18 @@
|
||||
{
|
||||
"name": "rea-agents",
|
||||
"version": "1.4.0",
|
||||
"version": "1.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "rea-agents",
|
||||
"version": "1.4.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",
|
||||
"@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",
|
||||
@@ -37,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",
|
||||
@@ -246,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",
|
||||
@@ -264,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
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "rea-agents",
|
||||
"version": "1.4.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,7 +89,7 @@
|
||||
"@babel/parser": "8.0.4",
|
||||
"@babel/types": "8.0.4",
|
||||
"@electron/asar": "4.2.0",
|
||||
"@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",
|
||||
@@ -113,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",
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
@@ -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
@@ -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 70-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
-60
@@ -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(
|
||||
@@ -338,16 +328,10 @@ 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) {
|
||||
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") ||
|
||||
@@ -357,25 +341,19 @@ try {
|
||||
)
|
||||
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");
|
||||
@@ -409,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" ||
|
||||
@@ -535,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);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ transport.stderr?.on("data", (chunk) => {
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const tools = await client.listTools();
|
||||
if (tools.tools.length !== 70)
|
||||
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: 70 })}\n`,
|
||||
`${JSON.stringify({ package: "rea-agents", version, mcpTools: 68 })}\n`,
|
||||
);
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { access, mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { WebSocketServer } from "ws";
|
||||
|
||||
import { CdpBrowserProvider } from "../dist/browser/CdpBrowserProvider.js";
|
||||
import {
|
||||
inspectWebPageInputSchema,
|
||||
listBrowserTargetsInputSchema,
|
||||
} from "../dist/domain/browserObservation.js";
|
||||
|
||||
const SECRET_VALUES = [
|
||||
"network-secret-value",
|
||||
"console-secret-value",
|
||||
"storage-secret-value",
|
||||
"websocket-secret-value",
|
||||
];
|
||||
|
||||
const executable = await browserExecutable();
|
||||
const profile = await mkdtemp(join(tmpdir(), "rea-real-browser-"));
|
||||
const site = await startSite();
|
||||
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_script_sources: true,
|
||||
}),
|
||||
);
|
||||
if (!withSource.ok) throw withSource.error;
|
||||
const source = withSource.value.scripts.items.find(
|
||||
(script) =>
|
||||
script.source.included &&
|
||||
script.source.content.includes("reaSourceMarker"),
|
||||
);
|
||||
if (source === undefined)
|
||||
throw new Error(
|
||||
"Real Chrome did not return explicitly approved script source",
|
||||
);
|
||||
|
||||
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,
|
||||
verified: true,
|
||||
})}\n`,
|
||||
);
|
||||
} finally {
|
||||
if (browser !== undefined) await stopProcess(browser);
|
||||
await site.close();
|
||||
await rm(profile, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
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.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",
|
||||
);
|
||||
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.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");
|
||||
}
|
||||
|
||||
async function startSite() {
|
||||
let port = 0;
|
||||
const server = createServer((request, response) => {
|
||||
if (request.url?.startsWith("/app.js") === true) {
|
||||
response.setHeader("content-type", "text/javascript");
|
||||
response.end(script(port));
|
||||
return;
|
||||
}
|
||||
if (request.url?.startsWith("/api") === true) {
|
||||
response.setHeader("content-type", "application/json");
|
||||
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="Verify browser">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)}`,
|
||||
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),
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function script(port) {
|
||||
return `
|
||||
export const reaSourceMarker = "approved-source-marker";
|
||||
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", "console-secret-value");
|
||||
fetch("/api?token=network-secret-value", {
|
||||
headers: { Authorization: "Bearer request-secret-value" }
|
||||
});
|
||||
const socket = new WebSocket("ws://127.0.0.1:${String(port)}/live?token=websocket-url-secret");
|
||||
socket.addEventListener("open", () => socket.send("websocket-secret-value"));
|
||||
socket.addEventListener("message", () => socket.close());
|
||||
};
|
||||
observe();
|
||||
setInterval(observe, 150);
|
||||
`;
|
||||
}
|
||||
|
||||
function delay(milliseconds) {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
async function stopProcess(child) {
|
||||
if (child.exitCode !== null) return;
|
||||
child.kill("SIGTERM");
|
||||
const exited = await Promise.race([
|
||||
new Promise((resolve) => child.once("exit", () => resolve(true))),
|
||||
delay(2_000).then(() => false),
|
||||
]);
|
||||
if (!exited) {
|
||||
child.kill("SIGKILL");
|
||||
await new Promise((resolve) => child.once("exit", resolve));
|
||||
}
|
||||
}
|
||||
@@ -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 70 tools");
|
||||
throw new Error("The real server did not expose the intended 68 tools");
|
||||
}
|
||||
|
||||
const options = { timeout };
|
||||
|
||||
@@ -1,30 +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: "13"
|
||||
tool_count: 70
|
||||
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, use
|
||||
`list_browser_targets` and `inspect_web_page` instead of opening a binary. 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, headers, bodies, cookies, storage values, console values,
|
||||
and WebSocket payloads are deliberately absent. Request script sources or
|
||||
storage key names only when the user separately approves those more sensitive
|
||||
outputs. Treat truncation and attach-window coverage as explicit limitations,
|
||||
and cite the returned Evidence v2 IDs.
|
||||
|
||||
## Understand the request
|
||||
|
||||
Identify the app and what the user wants to understand or build. Do not ask for information they already provided.
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -134,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,
|
||||
@@ -145,7 +144,7 @@ export const createArtifactNode = (input: {
|
||||
architecture: null,
|
||||
executable: input.executable,
|
||||
content_state: input.contentState,
|
||||
limitations: [...(input.limitations ?? [])],
|
||||
limitations: [],
|
||||
});
|
||||
|
||||
export const rootOccurrenceFor = (
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
artifactInventoryResultSchema,
|
||||
type ArtifactInventoryResult,
|
||||
type ArtifactNode,
|
||||
type IntegrityContradiction,
|
||||
} from "../domain/artifactGraph.js";
|
||||
import {
|
||||
classifyArtifactPath,
|
||||
@@ -62,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"];
|
||||
@@ -87,7 +71,6 @@ export interface ArtifactInventorySnapshot {
|
||||
readonly provenance: ReadonlyArray<
|
||||
ArtifactInventoryResult["provenance"][number]
|
||||
>;
|
||||
readonly integrity_contradictions: readonly IntegrityContradiction[];
|
||||
readonly limitations: readonly string[];
|
||||
}
|
||||
|
||||
@@ -99,7 +82,6 @@ export const inventoryArtifact = async (
|
||||
options: {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly nativeMount?: ArtifactNativeMountPolicy;
|
||||
readonly integrity?: ArtifactIntegrityPolicy;
|
||||
} = {},
|
||||
): Promise<ArtifactInventoryResult> => {
|
||||
const snapshot = await scanArtifactInventory(
|
||||
@@ -107,7 +89,6 @@ export const inventoryArtifact = async (
|
||||
limits,
|
||||
options.signal,
|
||||
options.nativeMount ?? NATIVE_MOUNT_DISABLED,
|
||||
options.integrity ?? STRICT_INTEGRITY_POLICY,
|
||||
);
|
||||
return paginateArtifactInventory(snapshot, page);
|
||||
};
|
||||
@@ -118,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. */
|
||||
@@ -137,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()
|
||||
@@ -155,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({
|
||||
@@ -177,46 +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 integrityContradictions = pendingContradictions.map(
|
||||
(contradiction): IntegrityContradiction => {
|
||||
const occurrence = occurrences.find(
|
||||
({ logical_path: path }) => path === 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 (
|
||||
|
||||
@@ -5,7 +5,6 @@ import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import { withinRoot } from "../reference/ReferenceSourceReaderPaths.js";
|
||||
import {
|
||||
scanCanonicalArtifactInventory,
|
||||
type ArtifactIntegrityPolicy,
|
||||
type ArtifactInventorySnapshot,
|
||||
} from "./ArtifactInventory.js";
|
||||
|
||||
@@ -15,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(
|
||||
@@ -31,11 +29,5 @@ export const scanAuthorizedArtifactInventory = async (
|
||||
"path",
|
||||
"Artifact path is outside approved input roots",
|
||||
);
|
||||
return scanCanonicalArtifactInventory(
|
||||
path,
|
||||
limits,
|
||||
signal,
|
||||
undefined,
|
||||
integrity,
|
||||
);
|
||||
return scanCanonicalArtifactInventory(path, limits, signal);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -131,12 +121,6 @@ export class BinarySession implements BinarySessionPort {
|
||||
return this.#providerIdentity;
|
||||
}
|
||||
|
||||
/** Observe runtime provider-health changes that affect discovery metadata. */
|
||||
onAvailabilityChanged(listener: () => void): () => void {
|
||||
this.#availabilityListeners.add(listener);
|
||||
return () => this.#availabilityListeners.delete(listener);
|
||||
}
|
||||
|
||||
/** Add one successful public observation to the session ledger. */
|
||||
recordEvidence(
|
||||
evidence: Evidence,
|
||||
@@ -207,36 +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 {
|
||||
return this.#investigationWorkspaces.get(
|
||||
`${workspaceId}:${String(revision)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 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,
|
||||
);
|
||||
}
|
||||
|
||||
/** Create an approved residual unknown and immutable mutation evidence. */
|
||||
recordUnknown(
|
||||
input: RecordUnknownInput,
|
||||
@@ -369,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);
|
||||
@@ -395,7 +348,6 @@ export class BinarySession implements BinarySessionPort {
|
||||
this.#evidence.clear();
|
||||
this.#snapshot.clear();
|
||||
this.#snapshotInvalidated = false;
|
||||
this.#clearRuntimeAvailability();
|
||||
return ok(null);
|
||||
});
|
||||
}
|
||||
@@ -423,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,
|
||||
@@ -508,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) {
|
||||
@@ -521,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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,61 +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";
|
||||
|
||||
type BrowserEvidenceInput = ListBrowserTargetsInput | InspectWebPageInput;
|
||||
type BrowserEvidenceResult = BrowserTargetList | WebPageInspection;
|
||||
|
||||
/** Create Evidence v2 for a policy-scoped external browser observation. */
|
||||
export const createBrowserEvidence = (
|
||||
operation: "list_browser_targets" | "inspect_web_page",
|
||||
input: BrowserEvidenceInput,
|
||||
result: BrowserEvidenceResult,
|
||||
provider: ProviderIdentity,
|
||||
): Evidence =>
|
||||
createEvidence(undefined, provider, {
|
||||
predicateType:
|
||||
operation === "list_browser_targets"
|
||||
? "rea.browser-target-list/v1"
|
||||
: "rea.web-page-inspection/v1",
|
||||
operation,
|
||||
parameters: browserParameters(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 browserParameters = (
|
||||
input: BrowserEvidenceInput,
|
||||
): EvidenceObservation["parameters"] => ({
|
||||
cdp_endpoint: input.cdp_endpoint,
|
||||
allowed_origins: input.allowed_origins,
|
||||
...("target_id" in input
|
||||
? {
|
||||
target_id: input.target_id,
|
||||
observation_ms: input.observation_ms,
|
||||
include_script_sources: input.include_script_sources,
|
||||
include_storage_keys: input.include_storage_keys,
|
||||
limits: input.limits,
|
||||
}
|
||||
: { offset: input.offset, limit: input.limit }),
|
||||
});
|
||||
@@ -1,23 +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 { 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>>;
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import type {
|
||||
InspectWebPageInput,
|
||||
ListBrowserTargetsInput,
|
||||
} from "../domain/browserObservation.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;
|
||||
};
|
||||
|
||||
const prepare = async (
|
||||
browser: BrowserObservationPort | undefined,
|
||||
authority: PermissionAuthority | undefined,
|
||||
input: ScopedBrowserInput,
|
||||
operation: "list_browser_targets" | "inspect_web_page",
|
||||
): 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 browserNetworkScope = (
|
||||
allowedOrigins: readonly string[],
|
||||
): "loopback" | "external" =>
|
||||
allowedOrigins.every((origin) => {
|
||||
const hostname = new URL(origin).hostname;
|
||||
return hostname === "127.0.0.1" || hostname === "[::1]";
|
||||
})
|
||||
? "loopback"
|
||||
: "external";
|
||||
@@ -1,203 +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;
|
||||
},
|
||||
) => {
|
||||
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 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 === "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 };
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -39,31 +38,22 @@ 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,
|
||||
};
|
||||
const left = await scanAuthorizedArtifactInventory(
|
||||
input.left_path,
|
||||
inputRoots,
|
||||
limits,
|
||||
signal,
|
||||
integrity,
|
||||
);
|
||||
const right = await scanAuthorizedArtifactInventory(
|
||||
input.right_path,
|
||||
inputRoots,
|
||||
limits,
|
||||
signal,
|
||||
integrity,
|
||||
);
|
||||
return ok({ left, right });
|
||||
} catch (cause: unknown) {
|
||||
@@ -84,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 });
|
||||
@@ -114,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),
|
||||
@@ -124,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,
|
||||
@@ -165,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,
|
||||
|
||||
@@ -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,
|
||||
@@ -57,8 +54,6 @@ export interface CrossVersionInvestigationExecution {
|
||||
readonly inputRoots: readonly string[];
|
||||
readonly session?: BinarySessionPort;
|
||||
readonly signal?: AbortSignal;
|
||||
readonly progress?: ProgressReporter;
|
||||
readonly integrityContinueEnabled?: boolean;
|
||||
}
|
||||
|
||||
/** Run or resume a deterministic cross-version artifact investigation. */
|
||||
@@ -81,36 +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"));
|
||||
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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -125,8 +103,6 @@ const continueInvestigation = async (context: {
|
||||
readonly snapshots: VersionSnapshots;
|
||||
readonly policy: EvidenceFilePolicy;
|
||||
readonly session: BinarySessionPort | undefined;
|
||||
readonly signal: AbortSignal | undefined;
|
||||
readonly progress: ProgressReporter | undefined;
|
||||
}): Promise<Result<CrossVersionInvestigationOutcome, AnalysisError>> => {
|
||||
const left = targetFor(context.snapshots.left);
|
||||
const right = targetFor(context.snapshots.right);
|
||||
@@ -134,31 +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);
|
||||
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,
|
||||
@@ -168,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;
|
||||
@@ -230,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,
|
||||
@@ -241,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,6 +1,4 @@
|
||||
import { parseConfig } from "../config.js";
|
||||
import { jsonObjectSchema } from "../domain/jsonValue.js";
|
||||
import { createServerIdentity } from "../serverIdentity.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,8 +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";
|
||||
|
||||
const WORKFLOW_PROVIDER = {
|
||||
id: "rea-workflow",
|
||||
@@ -58,16 +52,12 @@ export const runDirectAnalysis = async (
|
||||
options: {
|
||||
readonly logger?: Logger;
|
||||
readonly snapshotPath?: string | undefined;
|
||||
readonly signal?: AbortSignal;
|
||||
} = {},
|
||||
): Promise<JsonValue> =>
|
||||
withProcessCancellation(options.signal, (signal) =>
|
||||
runAnalysis(path, tool, arguments_, {
|
||||
logger: options.logger ?? silentLogger,
|
||||
snapshotPath: options.snapshotPath,
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
runAnalysis(path, tool, arguments_, {
|
||||
logger: options.logger ?? silentLogger,
|
||||
snapshotPath: options.snapshotPath,
|
||||
});
|
||||
|
||||
/** Execute one provider-native semantic operation with atomic provenance. */
|
||||
export const runProviderAnalysis = async (
|
||||
@@ -75,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 (
|
||||
@@ -93,94 +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>>,
|
||||
snapshotPath: string | undefined,
|
||||
): Promise<Result<null, AnalysisError>> => {
|
||||
const requests = [];
|
||||
if (snapshotPath !== undefined) {
|
||||
requests.push(
|
||||
{
|
||||
capability: "snapshot_read" as const,
|
||||
path: snapshotPath,
|
||||
access: "write" as const,
|
||||
},
|
||||
{
|
||||
capability: "snapshot_write" as const,
|
||||
path: snapshotPath,
|
||||
access: "write" as const,
|
||||
},
|
||||
);
|
||||
}
|
||||
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 runAnalysis = async (
|
||||
path: string,
|
||||
tool: NativeToolName | ArtifactToolName | DirectAnalysisTool,
|
||||
@@ -188,23 +89,11 @@ const runAnalysis = async (
|
||||
options: {
|
||||
readonly logger: Logger;
|
||||
readonly snapshotPath: string | undefined;
|
||||
readonly signal: AbortSignal;
|
||||
},
|
||||
): Promise<JsonValue> => {
|
||||
const { logger, signal, snapshotPath } = options;
|
||||
const { logger, snapshotPath } = options;
|
||||
const config = parseConfig(process.env);
|
||||
if (!config.ok) return cliError(config.error);
|
||||
const permissionAuthority = await loadConfiguredPermissionAuthority(
|
||||
config.value,
|
||||
);
|
||||
if (!permissionAuthority.ok) return cliError(permissionAuthority.error);
|
||||
const authorized = await authorizeAnalysis(
|
||||
permissionAuthority.value,
|
||||
tool,
|
||||
arguments_,
|
||||
snapshotPath,
|
||||
);
|
||||
if (!authorized.ok) return cliError(authorized.error);
|
||||
const unavailable = snapshotUnavailable(
|
||||
snapshotPath,
|
||||
config.value.hopperLoaderArgs,
|
||||
@@ -225,7 +114,7 @@ const runAnalysis = async (
|
||||
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;
|
||||
@@ -235,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(
|
||||
@@ -258,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(
|
||||
@@ -294,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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -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.",
|
||||
});
|
||||
|
||||
@@ -389,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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -174,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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
@@ -1,147 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, realpath, stat } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
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",
|
||||
"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" | "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 parsed = storeSchema.safeParse(
|
||||
JSON.parse(await readFile(path, "utf8")),
|
||||
);
|
||||
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 }));
|
||||
}
|
||||
};
|
||||
|
||||
const isNotFound = (cause: unknown): boolean =>
|
||||
cause instanceof Error && "code" in cause && cause.code === "ENOENT";
|
||||
@@ -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,8 +23,6 @@ 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 { installCanonicalSkill } from "./SetupSkill.js";
|
||||
import { setupPlan } from "./SetupPlan.js";
|
||||
import {
|
||||
@@ -41,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 =
|
||||
| {
|
||||
@@ -277,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,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
import {
|
||||
createResidualUnknown,
|
||||
updateResidualUnknown,
|
||||
residualUnknownSchema,
|
||||
type RecordUnknownInput,
|
||||
type ResidualUnknown,
|
||||
type UnknownStatus,
|
||||
type UpdateUnknownInput,
|
||||
} from "../domain/residualUnknown.js";
|
||||
import { UnknownRegistryError } from "../domain/errors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
export interface UnknownRegistryLimits {
|
||||
readonly maxRecords: number;
|
||||
readonly maxRelationships: number;
|
||||
}
|
||||
|
||||
type UnknownRegistryMutation = Result<ResidualUnknown, UnknownRegistryError>;
|
||||
|
||||
type UnknownRegistryQuery = Result<
|
||||
readonly ResidualUnknown[],
|
||||
UnknownRegistryError
|
||||
>;
|
||||
|
||||
type UnknownRegistryVerify = Result<
|
||||
{
|
||||
readonly valid: boolean;
|
||||
readonly truthVerified: boolean;
|
||||
readonly unknown: ResidualUnknown;
|
||||
},
|
||||
UnknownRegistryError
|
||||
>;
|
||||
|
||||
/**
|
||||
* Persistent, bounded registry of residual unknowns linked to evidence.
|
||||
*
|
||||
* Each unknown carries a stable ID derived from its question and scope,
|
||||
* an immutable revision graph, and evidence-qualified resolution state.
|
||||
*/
|
||||
export class UnknownRegistry {
|
||||
readonly #revisions = new Map<string, ResidualUnknown>();
|
||||
readonly #heads = new Map<string, ResidualUnknown>();
|
||||
|
||||
constructor(private readonly limits: UnknownRegistryLimits) {
|
||||
if (!Number.isSafeInteger(limits.maxRecords) || limits.maxRecords < 1)
|
||||
throw new RangeError("maxRecords must be a positive safe integer");
|
||||
if (
|
||||
!Number.isSafeInteger(limits.maxRelationships) ||
|
||||
limits.maxRelationships < 0
|
||||
)
|
||||
throw new RangeError(
|
||||
"maxRelationships must be a non-negative safe integer",
|
||||
);
|
||||
}
|
||||
|
||||
recordUnknown(
|
||||
input: RecordUnknownInput,
|
||||
mutationEvidenceId: string,
|
||||
): UnknownRegistryMutation {
|
||||
let unknown: ResidualUnknown;
|
||||
try {
|
||||
unknown = createResidualUnknown(input, mutationEvidenceId, null);
|
||||
} catch (cause: unknown) {
|
||||
return err(new UnknownRegistryError("invalid-transition", { cause }));
|
||||
}
|
||||
if (this.#heads.has(unknown.unknown_id))
|
||||
return err(new UnknownRegistryError("already-exists"));
|
||||
return this.#append(unknown);
|
||||
}
|
||||
|
||||
updateUnknown(
|
||||
input: UpdateUnknownInput,
|
||||
mutationEvidenceId: string,
|
||||
): UnknownRegistryMutation {
|
||||
const current = this.#heads.get(input.unknown_id);
|
||||
if (current === undefined)
|
||||
return err(new UnknownRegistryError("not-found"));
|
||||
if (current.revision !== input.expected_revision)
|
||||
return err(new UnknownRegistryError("revision-conflict"));
|
||||
let updated: ResidualUnknown;
|
||||
try {
|
||||
updated = updateResidualUnknown(current, input, mutationEvidenceId);
|
||||
} catch (cause: unknown) {
|
||||
return err(new UnknownRegistryError("invalid-transition", { cause }));
|
||||
}
|
||||
return this.#append(updated);
|
||||
}
|
||||
|
||||
listUnknowns(
|
||||
filters: {
|
||||
readonly status?: UnknownStatus;
|
||||
readonly severity?: ResidualUnknown["severity"];
|
||||
readonly domain?: string;
|
||||
} = {},
|
||||
): UnknownRegistryQuery {
|
||||
return ok(
|
||||
[...this.#heads.values()]
|
||||
.filter(
|
||||
(unknown) =>
|
||||
(filters.status === undefined ||
|
||||
unknown.status === filters.status) &&
|
||||
(filters.severity === undefined ||
|
||||
unknown.severity === filters.severity) &&
|
||||
(filters.domain === undefined || unknown.domain === filters.domain),
|
||||
)
|
||||
.sort((left, right) => left.unknown_id.localeCompare(right.unknown_id)),
|
||||
);
|
||||
}
|
||||
|
||||
verifyUnknownResolution(unknownId: string): UnknownRegistryVerify {
|
||||
const unknown = this.#heads.get(unknownId);
|
||||
if (unknown === undefined)
|
||||
return err(new UnknownRegistryError("not-found"));
|
||||
return ok({
|
||||
valid: unknown.status === "resolved",
|
||||
truthVerified: unknown.resolution?.disposition === "verified",
|
||||
unknown,
|
||||
});
|
||||
}
|
||||
|
||||
get(unknownId: string): ResidualUnknown | undefined {
|
||||
const unknown = this.#heads.get(unknownId);
|
||||
return unknown === undefined ? undefined : structuredClone(unknown);
|
||||
}
|
||||
|
||||
has(unknownId: string): boolean {
|
||||
return this.#heads.has(unknownId);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.#revisions.clear();
|
||||
this.#heads.clear();
|
||||
}
|
||||
|
||||
#append(unknown: ResidualUnknown): UnknownRegistryMutation {
|
||||
const pending = this.#revisions.size + 1;
|
||||
if (pending > this.limits.maxRecords)
|
||||
return err(new UnknownRegistryError("limit"));
|
||||
if (unknown.relationships.length > this.limits.maxRelationships)
|
||||
return err(new UnknownRegistryError("limit"));
|
||||
try {
|
||||
residualUnknownSchema.parse(unknown);
|
||||
} catch (cause: unknown) {
|
||||
return err(new UnknownRegistryError("integrity", { cause }));
|
||||
}
|
||||
this.#revisions.set(
|
||||
`${unknown.unknown_id}:${String(unknown.revision)}`,
|
||||
unknown,
|
||||
);
|
||||
this.#heads.set(unknown.unknown_id, unknown);
|
||||
return ok(unknown);
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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),
|
||||
]),
|
||||
|
||||
@@ -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,229 +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 {
|
||||
AnalysisError,
|
||||
BrowserObservationError,
|
||||
ProviderAdapterError,
|
||||
} from "../domain/errors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { CdpConnection } from "./CdpConnection.js";
|
||||
import {
|
||||
discoverCdpEndpoint,
|
||||
type CdpEndpointDiscovery,
|
||||
type CdpEndpointTarget,
|
||||
} from "./CdpEndpoint.js";
|
||||
import { capturePage } from "./CdpPageCapture.js";
|
||||
|
||||
const IDENTITY: ProviderIdentity = {
|
||||
id: "rea-cdp-browser",
|
||||
name: "REA Chrome DevTools Protocol observation provider",
|
||||
version: "1",
|
||||
};
|
||||
|
||||
/** 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.",
|
||||
],
|
||||
}),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return err(providerError(cause, "list_browser_targets"));
|
||||
}
|
||||
}
|
||||
|
||||
async inspectPage(
|
||||
input: InspectWebPageInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<WebPageInspection, AnalysisError>> {
|
||||
let connection: CdpConnection | undefined;
|
||||
let sessionId: string | undefined;
|
||||
try {
|
||||
const discovery = await discoverCdpEndpoint(
|
||||
input.cdp_endpoint,
|
||||
"inspect_web_page",
|
||||
options.signal,
|
||||
);
|
||||
const target = authorizeTarget(discovery, input);
|
||||
connection = await CdpConnection.connect(
|
||||
discovery.browserWebSocketUrl,
|
||||
options.signal,
|
||||
);
|
||||
const attached = await connection.send(
|
||||
"Target.attachToTarget",
|
||||
{ targetId: target.id, flatten: true },
|
||||
undefined,
|
||||
options.signal,
|
||||
);
|
||||
sessionId = attachedSessionId(attached);
|
||||
const result = await capturePage({
|
||||
connection,
|
||||
sessionId,
|
||||
discovery,
|
||||
target,
|
||||
input,
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
...(options.progress === undefined
|
||||
? {}
|
||||
: { progress: options.progress }),
|
||||
});
|
||||
return ok(webPageInspectionSchema.parse(result));
|
||||
} catch (cause: unknown) {
|
||||
return err(providerError(cause, "inspect_web_page"));
|
||||
} finally {
|
||||
if (connection !== undefined) {
|
||||
if (sessionId !== undefined)
|
||||
await bestEffortCleanup(connection, sessionId);
|
||||
await connection.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const selectTargets = (
|
||||
discovery: CdpEndpointDiscovery,
|
||||
allowedOrigins: ReadonlySet<string>,
|
||||
): {
|
||||
readonly allowed: readonly BrowserTargetList["targets"]["items"][number][];
|
||||
readonly excluded: BrowserTargetList["excluded"];
|
||||
} => {
|
||||
const allowed = [];
|
||||
let disallowedOrigin = 0;
|
||||
let unsupportedUrl = 0;
|
||||
let nonPage = 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;
|
||||
}
|
||||
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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const authorizeTarget = (
|
||||
discovery: CdpEndpointDiscovery,
|
||||
input: InspectWebPageInput,
|
||||
): 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 attachedSessionId = (value: unknown): string => {
|
||||
if (typeof value !== "object" || value === null || !("sessionId" in value))
|
||||
throw new BrowserObservationError("inspect_web_page", "protocol_error");
|
||||
if (typeof value.sessionId !== "string" || value.sessionId.length > 256)
|
||||
throw new BrowserObservationError("inspect_web_page", "protocol_error");
|
||||
return value.sessionId;
|
||||
};
|
||||
|
||||
const bestEffortCleanup = async (
|
||||
connection: CdpConnection,
|
||||
sessionId: string,
|
||||
): Promise<void> => {
|
||||
for (const method of [
|
||||
"Network.disable",
|
||||
"Debugger.disable",
|
||||
"Runtime.disable",
|
||||
"Page.disable",
|
||||
]) {
|
||||
try {
|
||||
await connection.send(method, {}, sessionId);
|
||||
} catch {
|
||||
// Cleanup continues so detach is still attempted.
|
||||
}
|
||||
}
|
||||
try {
|
||||
await connection.send("Target.detachFromTarget", { sessionId });
|
||||
} catch {
|
||||
// Closing REA's socket is the final non-destructive cleanup boundary.
|
||||
}
|
||||
};
|
||||
|
||||
const providerError = (
|
||||
cause: unknown,
|
||||
operation: "list_browser_targets" | "inspect_web_page",
|
||||
): AnalysisError =>
|
||||
cause instanceof AnalysisError
|
||||
? cause
|
||||
: new ProviderAdapterError(IDENTITY.id, operation, { cause });
|
||||
@@ -1,198 +0,0 @@
|
||||
import type {
|
||||
InspectWebPageInput,
|
||||
WebPageInspection,
|
||||
} from "../domain/browserObservation.js";
|
||||
import {
|
||||
allowedSanitizedUrl,
|
||||
boundedText,
|
||||
numberValue,
|
||||
recordValue,
|
||||
recordsValue,
|
||||
requiredRecord,
|
||||
stringValue,
|
||||
type UnknownRecord,
|
||||
} from "./CdpCaptureValues.js";
|
||||
|
||||
/** Normalize allowed frames while retaining a bounded prefix and total count. */
|
||||
export const captureFrames = (
|
||||
result: unknown,
|
||||
allowedOrigins: ReadonlySet<string>,
|
||||
maximum: number,
|
||||
): {
|
||||
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 ||
|
||||
sanitized === undefined ||
|
||||
sanitized.origin === null
|
||||
)
|
||||
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,
|
||||
): {
|
||||
readonly total: number;
|
||||
readonly items: WebPageInspection["resources"];
|
||||
} => {
|
||||
const root = recordValue(requiredRecord(result).frameTree);
|
||||
if (root === undefined) return { total: 0, items: [] };
|
||||
const items: WebPageInspection["resources"] = [];
|
||||
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) continue;
|
||||
total += 1;
|
||||
if (items.length >= maximum) continue;
|
||||
const contentSize = numberValue(resource.contentSize);
|
||||
items.push({
|
||||
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,
|
||||
): {
|
||||
readonly total: number;
|
||||
readonly nodes: WebPageInspection["dom"]["nodes"];
|
||||
} => {
|
||||
const root = requiredRecord(result);
|
||||
const strings = Array.isArray(root.strings)
|
||||
? root.strings.map((value) => stringValue(value) ?? "")
|
||||
: [];
|
||||
const nodes: WebPageInspection["dom"]["nodes"] = [];
|
||||
let total = 0;
|
||||
for (const document of recordsValue(root.documents)) {
|
||||
const documentUrl = indexedString(strings, document.documentURL);
|
||||
if (allowedSanitizedUrl(documentUrl, allowedOrigins) === undefined)
|
||||
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);
|
||||
nodes.push({
|
||||
index: nodes.length,
|
||||
parent_index: parent < 0 ? -1 : baseIndex + parent,
|
||||
node_type: Math.max(0, Math.trunc(nodeTypes[index] ?? 0)),
|
||||
node_name: indexedString(strings, nodeNames[index]).slice(0, 256),
|
||||
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),
|
||||
});
|
||||
}
|
||||
}
|
||||
return { total, nodes };
|
||||
};
|
||||
|
||||
export const captureAccessibility = (
|
||||
results: readonly unknown[],
|
||||
maximum: number,
|
||||
): {
|
||||
readonly total: number;
|
||||
readonly nodes: WebPageInspection["accessibility"]["nodes"];
|
||||
} => {
|
||||
const all = results.flatMap((result) =>
|
||||
recordsValue(requiredRecord(result).nodes),
|
||||
);
|
||||
return {
|
||||
total: all.length,
|
||||
nodes: all.slice(0, maximum).map((node) => ({
|
||||
node_id: (stringValue(node.nodeId) ?? "").slice(0, 256),
|
||||
parent_id: (stringValue(node.parentId) ?? "").slice(0, 256) || null,
|
||||
role: axText(node.role),
|
||||
name: axText(node.name),
|
||||
description: axText(node.description),
|
||||
ignored: node.ignored === true,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
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];
|
||||
})
|
||||
: [];
|
||||
@@ -1,307 +0,0 @@
|
||||
import type {
|
||||
InspectWebPageInput,
|
||||
WebPageInspection,
|
||||
} from "../domain/browserObservation.js";
|
||||
import type { CdpEvent } from "./CdpConnection.js";
|
||||
import {
|
||||
allowedSanitizedUrl,
|
||||
boundedText,
|
||||
isHttpUrl,
|
||||
numberValue,
|
||||
recordValue,
|
||||
recordsValue,
|
||||
stringValue,
|
||||
type UnknownRecord,
|
||||
} from "./CdpCaptureValues.js";
|
||||
|
||||
export interface CapturedScript {
|
||||
readonly scriptId: 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;
|
||||
}
|
||||
|
||||
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"] = [];
|
||||
dropped = 0;
|
||||
originViolation = false;
|
||||
navigationDuringCapture = false;
|
||||
#mainFrameId: string | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly input: InspectWebPageInput,
|
||||
private readonly allowedOrigins: ReadonlySet<string>,
|
||||
) {}
|
||||
|
||||
beginAuthorizedFrame(frameId: string): void {
|
||||
this.#mainFrameId = frameId;
|
||||
this.originViolation = false;
|
||||
this.navigationDuringCapture = false;
|
||||
this.dropped = 0;
|
||||
this.scripts.clear();
|
||||
this.network.clear();
|
||||
this.allowedWebSockets.clear();
|
||||
this.console.length = 0;
|
||||
this.websockets.length = 0;
|
||||
}
|
||||
|
||||
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 ||
|
||||
sanitized === undefined
|
||||
)
|
||||
return;
|
||||
if (
|
||||
this.scripts.size >= this.input.limits.max_scripts &&
|
||||
!this.scripts.has(scriptId)
|
||||
) {
|
||||
this.dropped += 1;
|
||||
return;
|
||||
}
|
||||
this.scripts.set(scriptId, {
|
||||
scriptId,
|
||||
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: this.#sourceMap(params.sourceMapURL),
|
||||
});
|
||||
}
|
||||
|
||||
#sourceMap(value: unknown): string | null {
|
||||
const sanitized = allowedSanitizedUrl(value, this.allowedOrigins);
|
||||
return sanitized?.url ?? null;
|
||||
}
|
||||
|
||||
#request(params: UnknownRecord): void {
|
||||
const requestId = stringValue(params.requestId);
|
||||
const request = recordValue(params.request);
|
||||
const sanitized = allowedSanitizedUrl(request?.url, this.allowedOrigins);
|
||||
if (requestId === undefined || requestId.length > 256) return;
|
||||
if (request === undefined || sanitized === undefined) {
|
||||
this.network.delete(requestId);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
this.network.size >= this.input.limits.max_network_events &&
|
||||
!this.network.has(requestId)
|
||||
) {
|
||||
this.dropped += 1;
|
||||
return;
|
||||
}
|
||||
const initiator = recordValue(params.initiator);
|
||||
const initiatorUrl = allowedSanitizedUrl(
|
||||
firstCallFrame(initiator)?.url,
|
||||
this.allowedOrigins,
|
||||
);
|
||||
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(firstCallFrame(initiator)?.lineNumber),
|
||||
column: integerOrNull(firstCallFrame(initiator)?.columnNumber),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
#response(params: UnknownRecord): void {
|
||||
const requestId = stringValue(params.requestId);
|
||||
if (requestId === undefined || requestId.length > 256) return;
|
||||
const current = this.network.get(requestId);
|
||||
const response = recordValue(params.response);
|
||||
const sanitized = allowedSanitizedUrl(response?.url, this.allowedOrigins);
|
||||
if (current === undefined) return;
|
||||
if (response === undefined || sanitized === undefined) {
|
||||
this.network.delete(requestId);
|
||||
return;
|
||||
}
|
||||
this.network.set(requestId, {
|
||||
...current,
|
||||
status: numberValue(response.status) ?? null,
|
||||
mime_type: boundedText(response.mimeType, 256),
|
||||
});
|
||||
}
|
||||
|
||||
#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));
|
||||
const source = allowedSanitizedUrl(frame?.url, this.allowedOrigins);
|
||||
if (frame === undefined || source === undefined) return;
|
||||
if (this.console.length >= this.input.limits.max_console_events) {
|
||||
this.dropped += 1;
|
||||
return;
|
||||
}
|
||||
this.console.push({
|
||||
type: (stringValue(params.type) ?? "unknown").slice(0, 100),
|
||||
timestamp: numberValue(params.timestamp) ?? 0,
|
||||
argument_types: recordsValue(params.args)
|
||||
.map((argument) =>
|
||||
(stringValue(argument.type) ?? "unknown").slice(0, 100),
|
||||
)
|
||||
.slice(0, 100),
|
||||
url: source?.url ?? null,
|
||||
line: integerOrNull(frame?.lineNumber),
|
||||
column: integerOrNull(frame?.columnNumber),
|
||||
});
|
||||
}
|
||||
|
||||
#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.dropped += 1;
|
||||
return;
|
||||
}
|
||||
const response = recordValue(params.response);
|
||||
const payload = stringValue(response?.payloadData) ?? "";
|
||||
const opcode = Math.max(0, Math.trunc(numberValue(response?.opcode) ?? 0));
|
||||
this.websockets.push({
|
||||
request_id: requestId,
|
||||
direction,
|
||||
opcode,
|
||||
payload_bytes:
|
||||
opcode === 1
|
||||
? Buffer.byteLength(payload)
|
||||
: Buffer.from(payload, "base64").byteLength,
|
||||
});
|
||||
}
|
||||
|
||||
#websocketCreated(params: UnknownRecord): void {
|
||||
const requestId = stringValue(params.requestId);
|
||||
const rawUrl = stringValue(params.url);
|
||||
if (
|
||||
requestId === undefined ||
|
||||
requestId.length > 256 ||
|
||||
rawUrl === undefined
|
||||
)
|
||||
return;
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(rawUrl);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (parsed.protocol === "ws:") parsed.protocol = "http:";
|
||||
else if (parsed.protocol === "wss:") parsed.protocol = "https:";
|
||||
else return;
|
||||
if (
|
||||
this.allowedOrigins.has(parsed.origin) &&
|
||||
(this.allowedWebSockets.has(requestId) ||
|
||||
this.allowedWebSockets.size < this.input.limits.max_websocket_events)
|
||||
)
|
||||
this.allowedWebSockets.add(requestId);
|
||||
else if (this.allowedOrigins.has(parsed.origin)) this.dropped += 1;
|
||||
}
|
||||
|
||||
#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 => {
|
||||
const callFrames = recordsValue(value?.callFrames);
|
||||
return callFrames[0];
|
||||
};
|
||||
|
||||
const integerOrNull = (value: unknown): number | null => {
|
||||
const number = numberValue(value);
|
||||
return number === undefined ? null : Math.max(0, Math.trunc(number));
|
||||
};
|
||||
@@ -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;
|
||||
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)];
|
||||
})
|
||||
: [];
|
||||
@@ -1,98 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
BrowserObservationError,
|
||||
} from "../domain/errors.js";
|
||||
import { sanitizeBrowserUrl } from "../domain/browserObservation.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; sha256: string; bytes: number; content: string } => ({
|
||||
included: true,
|
||||
sha256: createHash("sha256").update(source).digest("hex"),
|
||||
bytes: Buffer.byteLength(source),
|
||||
content: source,
|
||||
});
|
||||
|
||||
export const sourceExcluded = (
|
||||
reason: string,
|
||||
): { included: false; reason: string } => ({ included: false, reason });
|
||||
|
||||
export const delayWithCancellation = async (
|
||||
durationMs: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> => {
|
||||
if (durationMs === 0) return;
|
||||
if (signal?.aborted === true)
|
||||
throw new AnalysisCancelledError("inspect_web_page");
|
||||
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("inspect_web_page"));
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
if (signal?.aborted === true) onAbort();
|
||||
});
|
||||
};
|
||||
@@ -1,272 +0,0 @@
|
||||
import WebSocket, { type RawData } from "ws";
|
||||
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisError,
|
||||
AnalysisTimeoutError,
|
||||
BrowserObservationError,
|
||||
} 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 a browser-level CDP socket. */
|
||||
export class CdpConnection {
|
||||
readonly #pending = new Map<number, PendingCommand>();
|
||||
readonly #listeners = new Set<(event: CdpEvent) => void>();
|
||||
#nextId = 1;
|
||||
#closed = false;
|
||||
#protocolFailed = false;
|
||||
|
||||
private constructor(private readonly socket: WebSocket) {
|
||||
socket.on("message", (data) => this.#receive(data));
|
||||
socket.on("close", () => this.#failPending("disconnected"));
|
||||
socket.on("error", () => this.#failPending("disconnected"));
|
||||
}
|
||||
|
||||
/** Connect to one already-validated loopback browser WebSocket. */
|
||||
static async connect(
|
||||
url: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CdpConnection> {
|
||||
if (signal?.aborted === true)
|
||||
throw new AnalysisCancelledError("inspect_web_page");
|
||||
const socket = new WebSocket(url, {
|
||||
handshakeTimeout: CONNECT_TIMEOUT_MS,
|
||||
maxPayload: MAX_PAYLOAD_BYTES,
|
||||
perMessageDeflate: false,
|
||||
});
|
||||
await waitForOpen(socket, signal);
|
||||
return new CdpConnection(socket);
|
||||
}
|
||||
|
||||
/** Subscribe to validated CDP event envelopes. */
|
||||
onEvent(listener: (event: CdpEvent) => void): () => void {
|
||||
this.#listeners.add(listener);
|
||||
return () => this.#listeners.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("inspect_web_page", "protocol_error");
|
||||
if (this.#closed || this.socket.readyState !== WebSocket.OPEN)
|
||||
throw new BrowserObservationError("inspect_web_page", "disconnected");
|
||||
if (this.#pending.size >= MAX_PENDING_COMMANDS)
|
||||
throw new BrowserObservationError("inspect_web_page", "payload_limit");
|
||||
if (signal?.aborted === true)
|
||||
throw new AnalysisCancelledError("inspect_web_page");
|
||||
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("inspect_web_page"));
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
const timer = setTimeout(() => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
this.#pending.delete(id);
|
||||
reject(
|
||||
new AnalysisTimeoutError("inspect_web_page", 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 ? { 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("inspect_web_page", "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("inspect_web_page", "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("inspect_web_page", reason));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> =>
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
terminateSocket(socket);
|
||||
reject(new AnalysisTimeoutError("inspect_web_page", CONNECT_TIMEOUT_MS));
|
||||
}, CONNECT_TIMEOUT_MS);
|
||||
const onOpen = (): void => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const onFailure = (cause: unknown): void => {
|
||||
cleanup();
|
||||
reject(
|
||||
signal?.aborted === true
|
||||
? new AnalysisCancelledError("inspect_web_page")
|
||||
: new BrowserObservationError(
|
||||
"inspect_web_page",
|
||||
"endpoint_unreachable",
|
||||
{ cause },
|
||||
),
|
||||
);
|
||||
};
|
||||
const onAbort = (): void => {
|
||||
terminateSocket(socket);
|
||||
onFailure(new AnalysisCancelledError("inspect_web_page"));
|
||||
};
|
||||
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();
|
||||
};
|
||||
@@ -1,207 +0,0 @@
|
||||
import { request } from "node:http";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisError,
|
||||
AnalysisTimeoutError,
|
||||
BrowserObservationError,
|
||||
} 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),
|
||||
});
|
||||
const endpointTargetsSchema = z.array(endpointTargetSchema).max(1_000);
|
||||
|
||||
export type CdpEndpointTarget = z.infer<typeof endpointTargetSchema>;
|
||||
|
||||
export interface CdpEndpointDiscovery {
|
||||
readonly browserWebSocketUrl: string;
|
||||
readonly version: {
|
||||
readonly product: string;
|
||||
readonly protocol_version: string;
|
||||
readonly revision: string;
|
||||
readonly user_agent: string;
|
||||
readonly js_version: string;
|
||||
};
|
||||
readonly targets: readonly CdpEndpointTarget[];
|
||||
}
|
||||
|
||||
type BrowserOperation = "list_browser_targets" | "inspect_web_page";
|
||||
|
||||
/** Read bounded CDP discovery endpoints without following redirects. */
|
||||
export const discoverCdpEndpoint = async (
|
||||
endpoint: string,
|
||||
operation: BrowserOperation,
|
||||
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 {
|
||||
browserWebSocketUrl: safeBrowserWebSocketUrl(
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
const parseEndpointValue = <Output>(
|
||||
schema: z.ZodType<Output>,
|
||||
input: unknown,
|
||||
operation: BrowserOperation,
|
||||
): Output => {
|
||||
const parsed = schema.safeParse(input);
|
||||
if (!parsed.success)
|
||||
throw new BrowserObservationError(operation, "invalid_endpoint_response", {
|
||||
cause: parsed.error,
|
||||
});
|
||||
return parsed.data;
|
||||
};
|
||||
|
||||
const safeBrowserWebSocketUrl = (
|
||||
endpoint: string,
|
||||
reported: string,
|
||||
operation: BrowserOperation,
|
||||
): string => {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(reported);
|
||||
} catch (cause: unknown) {
|
||||
throw new BrowserObservationError(operation, "invalid_endpoint_response", {
|
||||
cause,
|
||||
});
|
||||
}
|
||||
const trustedEndpoint = new URL(endpoint);
|
||||
if (
|
||||
parsed.protocol !== "ws:" ||
|
||||
parsed.port !== trustedEndpoint.port ||
|
||||
parsed.username !== "" ||
|
||||
parsed.password !== "" ||
|
||||
parsed.search !== "" ||
|
||||
parsed.hash !== "" ||
|
||||
!parsed.pathname.startsWith("/devtools/browser/")
|
||||
)
|
||||
throw new BrowserObservationError(operation, "invalid_endpoint_response");
|
||||
parsed.hostname = trustedEndpoint.hostname;
|
||||
return parsed.href;
|
||||
};
|
||||
|
||||
const readJson = async (
|
||||
url: URL,
|
||||
maximumBytes: number,
|
||||
operation: BrowserOperation,
|
||||
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: BrowserOperation,
|
||||
signal?: AbortSignal,
|
||||
): AnalysisError => {
|
||||
if (cause instanceof AnalysisError) return cause;
|
||||
if (signal?.aborted === true) return new AnalysisCancelledError(operation);
|
||||
return new BrowserObservationError(operation, "endpoint_unreachable", {
|
||||
cause,
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -1,454 +0,0 @@
|
||||
import type {
|
||||
InspectWebPageInput,
|
||||
WebPageInspection,
|
||||
} from "../domain/browserObservation.js";
|
||||
import type { ProgressReporter } from "../application/ProgressReporter.js";
|
||||
import { BrowserObservationError } from "../domain/errors.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,
|
||||
mainFrameUrl,
|
||||
} from "./CdpCaptureDocuments.js";
|
||||
import {
|
||||
allowedSanitizedUrl,
|
||||
delayWithCancellation,
|
||||
isHttpUrl,
|
||||
recordValue,
|
||||
recordsValue,
|
||||
requiredRecord,
|
||||
sourceExcluded,
|
||||
sourceResult,
|
||||
stringValue,
|
||||
} from "./CdpCaptureValues.js";
|
||||
|
||||
interface CaptureContext {
|
||||
readonly connection: CdpConnection;
|
||||
readonly sessionId: string;
|
||||
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 truncatedSections: Set<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"];
|
||||
}
|
||||
|
||||
/** Capture and normalize one attached page without evaluating page JavaScript. */
|
||||
export const capturePage = async (
|
||||
context: CaptureContext,
|
||||
): Promise<WebPageInspection> => {
|
||||
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.",
|
||||
"Network headers, request bodies, response bodies, cookies, storage values, console values, and WebSocket payloads are not retained.",
|
||||
"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.",
|
||||
],
|
||||
truncatedSections: new Set<string>(),
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
try {
|
||||
return await captureAuthorizedPage(state);
|
||||
} finally {
|
||||
removeListener();
|
||||
}
|
||||
};
|
||||
|
||||
const captureAuthorizedPage = async (
|
||||
state: CaptureState,
|
||||
): Promise<WebPageInspection> => {
|
||||
const { context, allowedOrigins, limitations, truncatedSections } = state;
|
||||
const { connection, sessionId, input, signal } = context;
|
||||
await authorizeObservationWindow(state);
|
||||
const frameResult = await authorizedFrameTree(context, allowedOrigins);
|
||||
const attachedUrl = mainFrameUrl(frameResult) ?? "";
|
||||
const frameCapture = captureFrames(
|
||||
frameResult,
|
||||
allowedOrigins,
|
||||
input.limits.max_frames,
|
||||
);
|
||||
const frames = frameCapture.items;
|
||||
if (frameCapture.total > frames.length) truncatedSections.add("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,
|
||||
);
|
||||
if (resourceCapture.total > resourceCapture.items.length)
|
||||
truncatedSections.add("resources");
|
||||
const dom = await capturePageDom(state);
|
||||
if (dom.total > dom.nodes.length) truncatedSections.add("dom");
|
||||
const accessibility = await accessibilityForFrames(
|
||||
context,
|
||||
frames,
|
||||
limitations,
|
||||
);
|
||||
if (accessibility.total > accessibility.nodes.length)
|
||||
truncatedSections.add("accessibility");
|
||||
const scripts = await captureScripts(
|
||||
context,
|
||||
state.events,
|
||||
truncatedSections,
|
||||
);
|
||||
const workerCapture = await captureWorkers(
|
||||
context,
|
||||
allowedOrigins,
|
||||
new Set(frames.map((frame) => frame.frame_id)),
|
||||
limitations,
|
||||
);
|
||||
if (workerCapture.total > workerCapture.items.length)
|
||||
truncatedSections.add("workers");
|
||||
const storageCapture = await captureStorage(
|
||||
context,
|
||||
frames[0]?.origin ?? new URL(attachedUrl).origin,
|
||||
limitations,
|
||||
);
|
||||
if (storageCapture.truncated) truncatedSections.add("storage_keys");
|
||||
if (state.events.dropped > 0) truncatedSections.add("events");
|
||||
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 normalizedInspection(state, {
|
||||
attachedUrl,
|
||||
frames,
|
||||
dom,
|
||||
accessibility,
|
||||
scripts,
|
||||
resources: resourceCapture.items,
|
||||
workers: workerCapture.items,
|
||||
storage: storageCapture.value,
|
||||
});
|
||||
};
|
||||
|
||||
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, 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);
|
||||
};
|
||||
|
||||
const normalizedInspection = (
|
||||
state: CaptureState,
|
||||
captured: CapturedSections,
|
||||
): WebPageInspection => ({
|
||||
schema_version: 1,
|
||||
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: {
|
||||
status: state.truncatedSections.size === 0 ? "complete" : "truncated",
|
||||
truncated_sections: [...state.truncatedSections].sort(),
|
||||
dropped_events: state.events.dropped,
|
||||
},
|
||||
frames: [...captured.frames],
|
||||
dom: { total_nodes: captured.dom.total, nodes: [...captured.dom.nodes] },
|
||||
accessibility: {
|
||||
total_nodes: captured.accessibility.total,
|
||||
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],
|
||||
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.signal);
|
||||
}
|
||||
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
|
||||
};
|
||||
|
||||
const enableObservationDomains = async (
|
||||
connection: CdpConnection,
|
||||
sessionId: string,
|
||||
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 (
|
||||
context: CaptureContext,
|
||||
frames: readonly { readonly frame_id: string }[],
|
||||
limitations: string[],
|
||||
): Promise<ReturnType<typeof captureAccessibility>> => {
|
||||
const nodes: WebPageInspection["accessibility"]["nodes"] = [];
|
||||
let total = 0;
|
||||
for (const frame of frames) {
|
||||
const result = await optionalCdpCommand(
|
||||
context,
|
||||
"Accessibility.getFullAXTree",
|
||||
{ depth: 32, frameId: frame.frame_id },
|
||||
limitations,
|
||||
);
|
||||
if (result === undefined) continue;
|
||||
const capture = captureAccessibility(
|
||||
[result],
|
||||
Math.max(0, context.input.limits.max_ax_nodes - nodes.length),
|
||||
);
|
||||
total += capture.total;
|
||||
nodes.push(...capture.nodes);
|
||||
}
|
||||
return { total, nodes };
|
||||
};
|
||||
|
||||
const captureScripts = async (
|
||||
context: CaptureContext,
|
||||
events: CdpCaptureEvents,
|
||||
truncatedSections: Set<string>,
|
||||
): Promise<WebPageInspection["scripts"]> => {
|
||||
let totalSourceBytes = 0;
|
||||
const items: WebPageInspection["scripts"]["items"] = [];
|
||||
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",
|
||||
);
|
||||
truncatedSections.add("script_sources");
|
||||
} else if (
|
||||
totalSourceBytes + script.length >
|
||||
context.input.limits.max_total_script_source_bytes
|
||||
) {
|
||||
source = sourceExcluded("total script source limit reached");
|
||||
truncatedSections.add("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",
|
||||
);
|
||||
truncatedSections.add("script_sources");
|
||||
} else {
|
||||
source = sourceResult(content);
|
||||
totalSourceBytes += bytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
items.push({
|
||||
script_id: script.scriptId,
|
||||
url: script.url,
|
||||
origin: script.origin,
|
||||
cdp_hash: script.hash,
|
||||
length: script.length,
|
||||
is_module: script.isModule,
|
||||
language: script.language,
|
||||
source_map_url: script.sourceMapUrl,
|
||||
source,
|
||||
});
|
||||
}
|
||||
return { total: events.scripts.size, items };
|
||||
};
|
||||
|
||||
const captureWorkers = async (
|
||||
context: CaptureContext,
|
||||
allowedOrigins: ReadonlySet<string>,
|
||||
frameIds: ReadonlySet<string>,
|
||||
limitations: string[],
|
||||
): Promise<{
|
||||
readonly total: number;
|
||||
readonly items: WebPageInspection["workers"];
|
||||
}> => {
|
||||
const result = await optionalCdpCommand(
|
||||
{ ...context, sessionId: undefined },
|
||||
"Target.getTargets",
|
||||
{},
|
||||
limitations,
|
||||
);
|
||||
const items: WebPageInspection["workers"] = [];
|
||||
let total = 0;
|
||||
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") || url === undefined || !relatedToPage)
|
||||
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 } : {}),
|
||||
});
|
||||
@@ -1,131 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import canonicalize from "canonicalize";
|
||||
import { z } from "zod";
|
||||
|
||||
import { PROMPT_CONTRACTS } from "./contracts/promptContracts.js";
|
||||
import { TOOL_CONTRACTS } from "./contracts/toolContracts.js";
|
||||
|
||||
/** Explicit CLI command inventory, kept separate from MCP tool counts. */
|
||||
export const CLI_COMMAND_NAMES = [
|
||||
"analyze",
|
||||
"inspect",
|
||||
"decompile",
|
||||
"setup",
|
||||
"doctor",
|
||||
"uninstall",
|
||||
"upgrade",
|
||||
"xrefs",
|
||||
"trace",
|
||||
"capabilities",
|
||||
"providers",
|
||||
"function",
|
||||
"search",
|
||||
"import-reference-source",
|
||||
"inventory-artifact",
|
||||
"extract-artifact",
|
||||
"inspect-macho",
|
||||
"inspect-signature",
|
||||
"list-architectures",
|
||||
"inspect-plist",
|
||||
"demangle-swift",
|
||||
"evidence-import",
|
||||
"evidence-export",
|
||||
"compare",
|
||||
"investigate-versions",
|
||||
"capture-process",
|
||||
"compare-process-captures",
|
||||
"policy",
|
||||
"list-browser-targets",
|
||||
"inspect-web-page",
|
||||
] as const;
|
||||
|
||||
const toolCatalog = TOOL_CONTRACTS.map((contract) => ({
|
||||
name: contract.name,
|
||||
surface: contract.kind,
|
||||
description: contract.description,
|
||||
annotations: contract.annotations,
|
||||
input_schema: z.toJSONSchema(contract.inputSchema, {
|
||||
unrepresentable: "any",
|
||||
}),
|
||||
output_schema: z.toJSONSchema(contract.outputSchema, {
|
||||
unrepresentable: "any",
|
||||
}),
|
||||
})).sort((left, right) => left.name.localeCompare(right.name));
|
||||
|
||||
const promptCatalog = PROMPT_CONTRACTS.map((contract) => ({
|
||||
name: contract.name,
|
||||
title: contract.title,
|
||||
description: contract.description,
|
||||
arguments: contract.arguments,
|
||||
steps: contract.steps,
|
||||
})).sort((left, right) => left.name.localeCompare(right.name));
|
||||
|
||||
const resourceCatalog = [
|
||||
{ name: "server-identity", uri: "rea://server/identity" },
|
||||
{ name: "active-residual-unknowns", uri: "rea://unknowns/active" },
|
||||
] as const;
|
||||
const resourceTemplateCatalog = [
|
||||
{ name: "session-evidence", uri_template: "rea://evidence/{evidenceId}" },
|
||||
{
|
||||
name: "evidence-section",
|
||||
uri_template: "rea://evidence/{evidenceId}/section/{section}",
|
||||
},
|
||||
{ name: "residual-unknown", uri_template: "rea://unknown/{unknownId}" },
|
||||
{
|
||||
name: "analysis-snapshot",
|
||||
uri_template: "rea://snapshot/{snapshotDigest}",
|
||||
},
|
||||
{
|
||||
name: "artifact-page",
|
||||
uri_template: "rea://artifact/{manifestId}/{collection}",
|
||||
},
|
||||
{
|
||||
name: "function-dossier",
|
||||
uri_template: "rea://function/{targetSha256}/{address}",
|
||||
},
|
||||
{
|
||||
name: "investigation-workspace-revision",
|
||||
uri_template: "rea://workspace/{workspaceId}/revision/{revision}",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const digest = (value: unknown): string => {
|
||||
const encoded = canonicalize(value);
|
||||
if (encoded === undefined)
|
||||
throw new TypeError("Catalog is not canonical JSON");
|
||||
return createHash("sha256").update(encoded).digest("hex");
|
||||
};
|
||||
|
||||
/** Stable, schema-sensitive identity for every public REA surface. */
|
||||
export const CATALOG_IDENTITY = {
|
||||
counts: {
|
||||
cli_commands: CLI_COMMAND_NAMES.length,
|
||||
mcp_tools: toolCatalog.length,
|
||||
mcp_prompts: promptCatalog.length,
|
||||
mcp_resources: resourceCatalog.length,
|
||||
mcp_resource_templates: resourceTemplateCatalog.length,
|
||||
},
|
||||
digests: {
|
||||
tools_sha256: digest(toolCatalog),
|
||||
prompts_sha256: digest(promptCatalog),
|
||||
resources_sha256: digest({ resourceCatalog, resourceTemplateCatalog }),
|
||||
combined_sha256: digest({
|
||||
cli: CLI_COMMAND_NAMES,
|
||||
tools: toolCatalog,
|
||||
prompts: promptCatalog,
|
||||
resources: resourceCatalog,
|
||||
resource_templates: resourceTemplateCatalog,
|
||||
}),
|
||||
},
|
||||
tools: toolCatalog.map(({ name, surface, annotations }) => ({
|
||||
name,
|
||||
surface,
|
||||
annotations: {
|
||||
read_only: annotations.readOnlyHint ?? false,
|
||||
destructive: annotations.destructiveHint ?? false,
|
||||
idempotent: annotations.idempotentHint ?? false,
|
||||
open_world: annotations.openWorldHint ?? true,
|
||||
},
|
||||
})),
|
||||
} as const;
|
||||
+3
-63
@@ -19,17 +19,8 @@ import { importReferenceSource } from "./application/ReferenceSourceImport.js";
|
||||
import { registerEvidenceCommands } from "./cliEvidenceCommands.js";
|
||||
import { registerProcessCommands } from "./cliProcessCommands.js";
|
||||
import { registerInvestigationCommands } from "./cliInvestigationCommands.js";
|
||||
import { registerUnknownCommands } from "./cliUnknownCommands.js";
|
||||
import { registerBrowserCommands } from "./cliBrowserCommands.js";
|
||||
import {
|
||||
AnalysisProtocolError,
|
||||
PermissionRequiredError,
|
||||
projectAnalysisError,
|
||||
} from "./domain/errors.js";
|
||||
import { projectAnalysisError } from "./domain/errors.js";
|
||||
import { projectReferenceSourceImportError } from "./application/ReferenceSourceImportTypes.js";
|
||||
import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js";
|
||||
import { registerPolicyCommands } from "./cliPolicyCommands.js";
|
||||
import { UnknownRegistry } from "./application/UnknownRegistry.js";
|
||||
|
||||
/**
|
||||
* Build the one-shot Incur CLI without starting Hopper at import time.
|
||||
@@ -44,7 +35,7 @@ export const createCli = (): ReturnType<typeof Cli.create> => {
|
||||
: parseLogLevel(process.env.REA_LOG_LEVEL),
|
||||
);
|
||||
const cli = Cli.create(PRODUCT_IDENTITY.cliBinary, {
|
||||
version: PRODUCT_IDENTITY.packageVersion,
|
||||
version: process.env.REA_PACKAGE_VERSION ?? "0.0.0-development",
|
||||
description: "Reverse engineer anything from your terminal or agent.",
|
||||
mcp: {
|
||||
command: PRODUCT_IDENTITY.mcpCommand,
|
||||
@@ -73,15 +64,8 @@ export const createCli = (): ReturnType<typeof Cli.create> => {
|
||||
registerArtifactCommands(cli, logger);
|
||||
registerInvestigationCommands(cli, logger);
|
||||
registerEvidenceCommands(cli, logger);
|
||||
registerUnknownCommands(
|
||||
cli,
|
||||
logger,
|
||||
new UnknownRegistry({ maxRecords: 1_000, maxRelationships: 100 }),
|
||||
);
|
||||
registerReferenceSourceCommand(cli, logger);
|
||||
registerProcessCommands(cli, logger);
|
||||
registerPolicyCommands(cli, logger);
|
||||
registerBrowserCommands(cli, logger);
|
||||
return cli;
|
||||
};
|
||||
|
||||
@@ -206,7 +190,7 @@ const registerSetupCommands = (
|
||||
run: ({ formatExplicit }) =>
|
||||
logCliCommand(logger, "upgrade", () =>
|
||||
runUpgrade(
|
||||
PRODUCT_IDENTITY.packageVersion,
|
||||
process.env.REA_PACKAGE_VERSION ?? "0.0.0-development",
|
||||
systemUpgradeHost(),
|
||||
formatExplicit ? "structured" : "human",
|
||||
),
|
||||
@@ -407,36 +391,6 @@ const registerReferenceSourceCommand = (
|
||||
error: "Import failed",
|
||||
...projectAnalysisError(config.error),
|
||||
};
|
||||
const authority = await loadConfiguredPermissionAuthority(config.value);
|
||||
if (!authority.ok)
|
||||
return {
|
||||
error: "Import failed",
|
||||
...projectAnalysisError(authority.error),
|
||||
};
|
||||
const authorized = await authority.value.authorize(
|
||||
{
|
||||
capability: "reference_read",
|
||||
roots: [args.root],
|
||||
executables: [],
|
||||
environment_names: [],
|
||||
network: "none",
|
||||
mount: false,
|
||||
operation_identity: `import_reference_source:${args.root}`,
|
||||
},
|
||||
"read",
|
||||
);
|
||||
if (!authorized.ok) {
|
||||
const error =
|
||||
authorized.error instanceof PermissionRequiredError
|
||||
? authorized.error
|
||||
: new AnalysisProtocolError(authorized.error.message, {
|
||||
cause: authorized.error,
|
||||
});
|
||||
return {
|
||||
error: "Import failed",
|
||||
...projectAnalysisError(error),
|
||||
};
|
||||
}
|
||||
const imported = await importReferenceSource({
|
||||
root: args.root,
|
||||
caller: "rea-cli",
|
||||
@@ -466,17 +420,7 @@ const registerArtifactCommands = (
|
||||
options: z.object({
|
||||
offset: z.number().int().min(0).default(0),
|
||||
limit: z.number().int().min(1).max(500).default(100),
|
||||
integrityPolicy: z.enum(["fail", "record-and-continue"]).default("fail"),
|
||||
integrityContinueApproved: z.boolean().default(false),
|
||||
maxIntegrityMismatches: z.number().int().min(1).max(100).default(10),
|
||||
nativeMountApproved: z.boolean().default(false),
|
||||
}),
|
||||
alias: {
|
||||
integrityPolicy: "integrity-policy",
|
||||
integrityContinueApproved: "integrity-continue-approved",
|
||||
maxIntegrityMismatches: "max-integrity-mismatches",
|
||||
nativeMountApproved: "native-mount-approved",
|
||||
},
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, "inventory-artifact", () =>
|
||||
runProviderAnalysis(
|
||||
@@ -489,10 +433,6 @@ const registerArtifactCommands = (
|
||||
occurrence_limit: options.limit,
|
||||
edge_offset: options.offset,
|
||||
edge_limit: options.limit,
|
||||
integrity_policy: options.integrityPolicy,
|
||||
integrity_continue_approved: options.integrityContinueApproved,
|
||||
max_integrity_mismatches: options.maxIntegrityMismatches,
|
||||
native_mount_approved: options.nativeMountApproved,
|
||||
},
|
||||
logger,
|
||||
),
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
import { Cli, z } from "incur";
|
||||
|
||||
import {
|
||||
inspectWebPage,
|
||||
listBrowserTargets,
|
||||
} from "./application/BrowserObservationService.js";
|
||||
import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js";
|
||||
import { CdpBrowserProvider } from "./browser/CdpBrowserProvider.js";
|
||||
import { logCliCommand } from "./cliLogging.js";
|
||||
import { parseConfig } from "./config.js";
|
||||
import {
|
||||
inspectWebPageInputSchema,
|
||||
listBrowserTargetsInputSchema,
|
||||
} from "./domain/browserObservation.js";
|
||||
import { AnalysisInputError, projectAnalysisError } from "./domain/errors.js";
|
||||
import type { JsonValue } from "./domain/jsonValue.js";
|
||||
import type { Logger } from "./logger.js";
|
||||
|
||||
const browserScopeOptions = {
|
||||
allowedOrigins: z
|
||||
.array(z.string().min(1))
|
||||
.optional()
|
||||
.describe(
|
||||
"Exact origins to observe; defaults to REA_BROWSER_ALLOWED_ORIGINS_JSON",
|
||||
),
|
||||
approved: z.boolean().default(false).describe("Approve passive observation"),
|
||||
};
|
||||
|
||||
/** Register CLI equivalents of the passive browser MCP tools. */
|
||||
export const registerBrowserCommands = (
|
||||
cli: ReturnType<typeof Cli.create>,
|
||||
logger: Logger,
|
||||
): void => {
|
||||
registerTargetList(cli, logger);
|
||||
registerPageInspection(cli, logger);
|
||||
};
|
||||
|
||||
const registerTargetList = (
|
||||
cli: ReturnType<typeof Cli.create>,
|
||||
logger: Logger,
|
||||
): void => {
|
||||
cli.command("list-browser-targets", {
|
||||
description: "List approved pages from a user-owned loopback CDP browser",
|
||||
args: z.object({
|
||||
endpoint: z.string().describe("Configured loopback CDP HTTP endpoint"),
|
||||
}),
|
||||
options: z.object({
|
||||
...browserScopeOptions,
|
||||
offset: z.number().int().min(0).default(0),
|
||||
limit: z.number().int().min(1).max(200).default(100),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, "list-browser-targets", async () => {
|
||||
const context = await browserContext();
|
||||
if (!context.ok) return context.error;
|
||||
const parsed = listBrowserTargetsInputSchema.safeParse({
|
||||
cdp_endpoint: args.endpoint,
|
||||
allowed_origins:
|
||||
options.allowedOrigins ?? context.allowedBrowserOrigins,
|
||||
approved: options.approved,
|
||||
offset: options.offset,
|
||||
limit: options.limit,
|
||||
});
|
||||
if (!parsed.success)
|
||||
return cliError(new AnalysisInputError("list_browser_targets"));
|
||||
const result = await listBrowserTargets(
|
||||
context.provider,
|
||||
context.authority,
|
||||
parsed.data,
|
||||
);
|
||||
return result.ok ? result.value : cliError(result.error);
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const registerPageInspection = (
|
||||
cli: ReturnType<typeof Cli.create>,
|
||||
logger: Logger,
|
||||
): void => {
|
||||
cli.command("inspect-web-page", {
|
||||
description: "Passively inspect one approved page through CDP",
|
||||
args: z.object({
|
||||
endpoint: z.string().describe("Configured loopback CDP HTTP endpoint"),
|
||||
targetId: z.string().describe("Target ID from list-browser-targets"),
|
||||
}),
|
||||
options: z.object({
|
||||
...browserScopeOptions,
|
||||
observationMs: z.number().int().min(0).max(10_000).default(500),
|
||||
includeScriptSources: z.boolean().default(false),
|
||||
includeStorageKeys: z.boolean().default(false),
|
||||
maxFrames: z.number().int().min(1).max(1_000).default(200),
|
||||
maxDomNodes: z.number().int().min(1).max(10_000).default(2_000),
|
||||
maxAxNodes: z.number().int().min(1).max(10_000).default(2_000),
|
||||
maxScripts: z.number().int().min(1).max(1_000).default(200),
|
||||
maxResources: z.number().int().min(1).max(10_000).default(2_000),
|
||||
maxWorkers: z.number().int().min(1).max(5_000).default(500),
|
||||
maxStorageKeys: z.number().int().min(1).max(10_000).default(1_000),
|
||||
maxScriptSourceBytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(4 * 1_024 * 1_024)
|
||||
.default(1_024 * 1_024),
|
||||
maxTotalScriptSourceBytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(16 * 1_024 * 1_024)
|
||||
.default(4 * 1_024 * 1_024),
|
||||
maxNetworkEvents: z.number().int().min(1).max(10_000).default(1_000),
|
||||
maxConsoleEvents: z.number().int().min(1).max(2_000).default(200),
|
||||
maxWebsocketEvents: z.number().int().min(1).max(5_000).default(500),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, "inspect-web-page", async () => {
|
||||
const context = await browserContext();
|
||||
if (!context.ok) return context.error;
|
||||
const parsed = inspectWebPageInputSchema.safeParse({
|
||||
cdp_endpoint: args.endpoint,
|
||||
allowed_origins:
|
||||
options.allowedOrigins ?? context.allowedBrowserOrigins,
|
||||
approved: options.approved,
|
||||
target_id: args.targetId,
|
||||
observation_ms: options.observationMs,
|
||||
include_script_sources: options.includeScriptSources,
|
||||
include_storage_keys: options.includeStorageKeys,
|
||||
limits: {
|
||||
max_frames: options.maxFrames,
|
||||
max_dom_nodes: options.maxDomNodes,
|
||||
max_ax_nodes: options.maxAxNodes,
|
||||
max_scripts: options.maxScripts,
|
||||
max_resources: options.maxResources,
|
||||
max_workers: options.maxWorkers,
|
||||
max_storage_keys: options.maxStorageKeys,
|
||||
max_script_source_bytes: options.maxScriptSourceBytes,
|
||||
max_total_script_source_bytes: options.maxTotalScriptSourceBytes,
|
||||
max_network_events: options.maxNetworkEvents,
|
||||
max_console_events: options.maxConsoleEvents,
|
||||
max_websocket_events: options.maxWebsocketEvents,
|
||||
},
|
||||
});
|
||||
if (!parsed.success)
|
||||
return cliError(new AnalysisInputError("inspect_web_page"));
|
||||
const result = await inspectWebPage(
|
||||
context.provider,
|
||||
context.authority,
|
||||
parsed.data,
|
||||
);
|
||||
return result.ok ? result.value : cliError(result.error);
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const browserContext = async () => {
|
||||
const config = parseConfig(process.env);
|
||||
if (!config.ok) return { ok: false as const, error: cliError(config.error) };
|
||||
const authority = await loadConfiguredPermissionAuthority(config.value);
|
||||
if (!authority.ok)
|
||||
return { ok: false as const, error: cliError(authority.error) };
|
||||
return {
|
||||
ok: true as const,
|
||||
authority: authority.value,
|
||||
provider: new CdpBrowserProvider(),
|
||||
allowedBrowserOrigins: config.value.browserAllowedOrigins,
|
||||
};
|
||||
};
|
||||
|
||||
const cliError = (
|
||||
error: Parameters<typeof projectAnalysisError>[0],
|
||||
): JsonValue => ({
|
||||
error: "Browser observation failed",
|
||||
...projectAnalysisError(error),
|
||||
});
|
||||
@@ -9,14 +9,7 @@ import { parseConfig } from "./config.js";
|
||||
import { logCliCommand } from "./cliLogging.js";
|
||||
import type { Logger } from "./logger.js";
|
||||
import type { JsonValue } from "./domain/jsonValue.js";
|
||||
import {
|
||||
AnalysisProtocolError,
|
||||
PermissionRequiredError,
|
||||
projectAnalysisError,
|
||||
type AnalysisError,
|
||||
} from "./domain/errors.js";
|
||||
import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js";
|
||||
import type { AppConfig } from "./config.js";
|
||||
import { projectAnalysisError, type AnalysisError } from "./domain/errors.js";
|
||||
|
||||
/** Register filesystem-gated Evidence v2 commands. */
|
||||
export const registerEvidenceCommands = (
|
||||
@@ -32,10 +25,6 @@ export const registerEvidenceCommands = (
|
||||
logCliCommand(logger, "evidence-import", async () => {
|
||||
const config = parseConfig(process.env);
|
||||
if (!config.ok) return cliError(config.error);
|
||||
const denied = await authorizeEvidence(config.value, [
|
||||
{ capability: "evidence_read", path: args.path, access: "read" },
|
||||
]);
|
||||
if (denied !== undefined) return cliError(denied);
|
||||
const imported = await importEvidenceBundleCommand(
|
||||
args.path,
|
||||
config.value.evidenceFilePolicy,
|
||||
@@ -56,11 +45,6 @@ export const registerEvidenceCommands = (
|
||||
logCliCommand(logger, "evidence-export", async () => {
|
||||
const config = parseConfig(process.env);
|
||||
if (!config.ok) return cliError(config.error);
|
||||
const denied = await authorizeEvidence(config.value, [
|
||||
{ capability: "evidence_read", path: args.source, access: "read" },
|
||||
{ capability: "evidence_write", path: args.output, access: "write" },
|
||||
]);
|
||||
if (denied !== undefined) return cliError(denied);
|
||||
const exported = await exportEvidenceBundleCommand(
|
||||
args.source,
|
||||
args.output,
|
||||
@@ -85,11 +69,6 @@ export const registerEvidenceCommands = (
|
||||
logCliCommand(logger, "compare", async () => {
|
||||
const config = parseConfig(process.env);
|
||||
if (!config.ok) return cliError(config.error);
|
||||
const denied = await authorizeEvidence(config.value, [
|
||||
{ capability: "evidence_read", path: args.left, access: "read" },
|
||||
{ capability: "evidence_read", path: args.right, access: "read" },
|
||||
]);
|
||||
if (denied !== undefined) return cliError(denied);
|
||||
const compared = await compareEvidenceBundlesCommand({
|
||||
leftPath: args.left,
|
||||
rightPath: args.right,
|
||||
@@ -102,39 +81,6 @@ export const registerEvidenceCommands = (
|
||||
});
|
||||
};
|
||||
|
||||
const authorizeEvidence = async (
|
||||
config: AppConfig,
|
||||
requests: readonly {
|
||||
readonly capability: "evidence_read" | "evidence_write";
|
||||
readonly path: string;
|
||||
readonly access: "read" | "write";
|
||||
}[],
|
||||
): Promise<AnalysisError | undefined> => {
|
||||
const authority = await loadConfiguredPermissionAuthority(config);
|
||||
if (!authority.ok) return authority.error;
|
||||
for (const request of requests) {
|
||||
const result = await authority.value.authorize(
|
||||
{
|
||||
capability: request.capability,
|
||||
roots: [request.path],
|
||||
executables: [],
|
||||
environment_names: [],
|
||||
network: "none",
|
||||
mount: false,
|
||||
operation_identity: `cli:${request.capability}:${request.path}`,
|
||||
},
|
||||
request.access,
|
||||
);
|
||||
if (!result.ok)
|
||||
return result.error instanceof PermissionRequiredError
|
||||
? result.error
|
||||
: new AnalysisProtocolError(result.error.message, {
|
||||
cause: result.error,
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const cliError = (error: AnalysisError): JsonValue => ({
|
||||
error: "Analysis failed",
|
||||
...projectAnalysisError(error),
|
||||
|
||||
@@ -3,14 +3,8 @@ import { Cli, z } from "incur";
|
||||
import { runCrossVersionInvestigation } from "./application/CrossVersionInvestigation.js";
|
||||
import { logCliCommand } from "./cliLogging.js";
|
||||
import { parseConfig } from "./config.js";
|
||||
import {
|
||||
AnalysisProtocolError,
|
||||
PermissionRequiredError,
|
||||
projectAnalysisError,
|
||||
type AnalysisError,
|
||||
} from "./domain/errors.js";
|
||||
import { projectAnalysisError, type AnalysisError } from "./domain/errors.js";
|
||||
import type { Logger } from "./logger.js";
|
||||
import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js";
|
||||
|
||||
const investigationOptionsSchema = z.object({
|
||||
yes: z
|
||||
@@ -34,9 +28,6 @@ const investigationOptionsSchema = z.object({
|
||||
.default(268_435_456),
|
||||
pageSize: z.number().int().min(1).max(500).default(500),
|
||||
changeLimit: z.number().int().min(1).max(500).default(500),
|
||||
integrityPolicy: z.enum(["fail", "record-and-continue"]).default("fail"),
|
||||
integrityContinueApproved: z.boolean().default(false),
|
||||
maxIntegrityMismatches: z.number().int().min(1).max(100).default(10),
|
||||
});
|
||||
|
||||
/** Register the persistent cross-version CLI workflow. */
|
||||
@@ -61,9 +52,6 @@ export const registerInvestigationCommands = (
|
||||
maxEntryBytes: "max-entry-bytes",
|
||||
pageSize: "page-size",
|
||||
changeLimit: "change-limit",
|
||||
integrityPolicy: "integrity-policy",
|
||||
integrityContinueApproved: "integrity-continue-approved",
|
||||
maxIntegrityMismatches: "max-integrity-mismatches",
|
||||
yes: "y",
|
||||
},
|
||||
run: ({ args, options }) =>
|
||||
@@ -88,46 +76,6 @@ const runInvestigation = async (
|
||||
};
|
||||
const config = parseConfig(process.env);
|
||||
if (!config.ok) return cliError(config.error);
|
||||
const authority = await loadConfiguredPermissionAuthority(config.value);
|
||||
if (!authority.ok) return cliError(authority.error);
|
||||
for (const scope of [
|
||||
{
|
||||
capability: "investigation_workspace_read" as const,
|
||||
roots: [args.workspacePath],
|
||||
access: "write" as const,
|
||||
},
|
||||
{
|
||||
capability: "investigation_workspace_write" as const,
|
||||
roots: [args.workspacePath],
|
||||
access: "write" as const,
|
||||
},
|
||||
{
|
||||
capability: "investigation_input" as const,
|
||||
roots: [args.leftPath, args.rightPath],
|
||||
access: "read" as const,
|
||||
},
|
||||
]) {
|
||||
const authorized = await authority.value.authorize(
|
||||
{
|
||||
capability: scope.capability,
|
||||
roots: scope.roots,
|
||||
executables: [],
|
||||
environment_names: [],
|
||||
network: "none",
|
||||
mount: false,
|
||||
operation_identity: `investigate_versions:${scope.capability}:${scope.roots.join(":")}`,
|
||||
},
|
||||
scope.access,
|
||||
);
|
||||
if (!authorized.ok)
|
||||
return cliError(
|
||||
authorized.error instanceof PermissionRequiredError
|
||||
? authorized.error
|
||||
: new AnalysisProtocolError(authorized.error.message, {
|
||||
cause: authorized.error,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const investigated = await runCrossVersionInvestigation(
|
||||
{
|
||||
approved: true,
|
||||
@@ -138,9 +86,6 @@ const runInvestigation = async (
|
||||
: { expected_workspace_revision: options.expectedRevision }),
|
||||
left_path: args.leftPath,
|
||||
right_path: args.rightPath,
|
||||
integrity_policy: options.integrityPolicy,
|
||||
integrity_continue_approved: options.integrityContinueApproved,
|
||||
max_integrity_mismatches: options.maxIntegrityMismatches,
|
||||
options: {
|
||||
max_entries: options.maxEntries,
|
||||
max_total_bytes: options.maxTotalBytes,
|
||||
@@ -153,10 +98,7 @@ const runInvestigation = async (
|
||||
},
|
||||
},
|
||||
config.value.evidenceFilePolicy,
|
||||
{
|
||||
inputRoots: config.value.investigationInputRoots,
|
||||
integrityContinueEnabled: config.value.artifactIntegrityContinueEnabled,
|
||||
},
|
||||
{ inputRoots: config.value.investigationInputRoots },
|
||||
);
|
||||
return investigated.ok
|
||||
? investigated.value.evidence
|
||||
|
||||
+3
-21
@@ -1,21 +1,5 @@
|
||||
import type { Logger } from "./logger.js";
|
||||
|
||||
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
/** Identify caller-visible CLI outcomes that mean the requested operation failed. */
|
||||
export const isCliOperationFailure = (value: unknown): boolean => {
|
||||
if (!isRecord(value)) return false;
|
||||
if (typeof value.error === "string") return true;
|
||||
if (value.healthy === false) return true;
|
||||
return (
|
||||
value.status === "failed" ||
|
||||
value.status === "needs_confirmation" ||
|
||||
value.status === "needs_human" ||
|
||||
value.status === "planned"
|
||||
);
|
||||
};
|
||||
|
||||
/** Log one CLI command with duration and a stable success or failure status. */
|
||||
export const logCliCommand = async <Value>(
|
||||
logger: Logger,
|
||||
@@ -25,16 +9,14 @@ export const logCliCommand = async <Value>(
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
const value = await execute();
|
||||
const failed = isCliOperationFailure(value);
|
||||
logger[failed ? "error" : "info"](
|
||||
logger.info(
|
||||
{
|
||||
command,
|
||||
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
|
||||
status: failed ? "error" : "ok",
|
||||
status: "ok",
|
||||
},
|
||||
failed ? "CLI command failed" : "CLI command completed",
|
||||
"CLI command completed",
|
||||
);
|
||||
if (failed) process.exitCode = 1;
|
||||
return value;
|
||||
} catch (cause: unknown) {
|
||||
logger.error(
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
import { Cli, z } from "incur";
|
||||
|
||||
import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js";
|
||||
import {
|
||||
readProjectPermissionStore,
|
||||
writeProjectPermissionStore,
|
||||
} from "./application/ProjectPermissionStore.js";
|
||||
import { logCliCommand } from "./cliLogging.js";
|
||||
import { parseConfig, type AppConfig } from "./config.js";
|
||||
import {
|
||||
PermissionRequiredError,
|
||||
projectAnalysisError,
|
||||
} from "./domain/errors.js";
|
||||
import type { PermissionCapability } from "./domain/permissionPolicy.js";
|
||||
import type { Logger } from "./logger.js";
|
||||
|
||||
const capabilitySchema = z.enum([
|
||||
"process_capture",
|
||||
"browser_observe",
|
||||
"evidence_read",
|
||||
"evidence_write",
|
||||
"investigation_input",
|
||||
"investigation_workspace_read",
|
||||
"investigation_workspace_write",
|
||||
"snapshot_read",
|
||||
"snapshot_write",
|
||||
"artifact_extract",
|
||||
"native_mount",
|
||||
"reference_read",
|
||||
]);
|
||||
|
||||
/** Register inspectable, dry-run, and revocation policy UX for the CLI. */
|
||||
export const registerPolicyCommands = (
|
||||
cli: ReturnType<typeof Cli.create>,
|
||||
logger: Logger,
|
||||
): void => {
|
||||
cli.command("policy", {
|
||||
description: "Inspect, explain, or revoke unified local permission grants",
|
||||
args: z.object({
|
||||
action: z.enum(["status", "list", "explain", "revoke"]),
|
||||
value: z.string().optional(),
|
||||
}),
|
||||
options: z.object({
|
||||
root: z.string().optional(),
|
||||
executable: z.string().optional(),
|
||||
environmentNames: z.array(z.string()).default([]),
|
||||
origins: z.array(z.string()).default([]),
|
||||
network: z.enum(["none", "loopback", "external"]).default("none"),
|
||||
mount: z.boolean().default(false),
|
||||
write: z.boolean().default(false),
|
||||
}),
|
||||
alias: { environmentNames: "environment-names" },
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, "policy", async () => {
|
||||
const config = parseConfig(process.env);
|
||||
if (!config.ok) return failure(config.error);
|
||||
if (args.action === "status")
|
||||
return {
|
||||
reload: "SIGHUP",
|
||||
restart_required: false,
|
||||
administrator_ceilings: config.value.permissionCeilings,
|
||||
project_store: config.value.permissionProjectStore ?? null,
|
||||
};
|
||||
if (args.action === "list")
|
||||
return readProjectGrants(config.value).then((result) =>
|
||||
result.ok ? { grants: result.grants } : result.error,
|
||||
);
|
||||
if (args.action === "revoke") {
|
||||
if (args.value === undefined)
|
||||
return { error: "Grant ID is required for policy revoke" };
|
||||
const result = await readProjectGrants(config.value);
|
||||
if (!result.ok) return result.error;
|
||||
const retained = result.grants.filter(
|
||||
({ grant_id }) => grant_id !== args.value,
|
||||
);
|
||||
if (retained.length === result.grants.length)
|
||||
return { error: "Grant was not found", grant_id: args.value };
|
||||
const written = await writeProjectPermissionStore(
|
||||
result.path,
|
||||
result.root,
|
||||
retained,
|
||||
);
|
||||
return written.ok
|
||||
? {
|
||||
revoked: args.value,
|
||||
reload: "Send SIGHUP to the REA MCP process",
|
||||
}
|
||||
: { error: written.error.message };
|
||||
}
|
||||
const capability = capabilitySchema.safeParse(args.value);
|
||||
if (!capability.success)
|
||||
return {
|
||||
error: "A registered capability is required for policy explain",
|
||||
};
|
||||
return explain(config.value, capability.data, options);
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const explain = async (
|
||||
config: AppConfig,
|
||||
capability: PermissionCapability,
|
||||
options: {
|
||||
readonly root?: string | undefined;
|
||||
readonly executable?: string | undefined;
|
||||
readonly environmentNames: readonly string[];
|
||||
readonly origins: readonly string[];
|
||||
readonly network: "none" | "loopback" | "external";
|
||||
readonly mount: boolean;
|
||||
readonly write: boolean;
|
||||
},
|
||||
) => {
|
||||
const authority = await loadConfiguredPermissionAuthority(config);
|
||||
if (!authority.ok) return failure(authority.error);
|
||||
const evaluated = await authority.value.explain(
|
||||
{
|
||||
capability,
|
||||
roots: options.root === undefined ? [] : [options.root],
|
||||
executables: options.executable === undefined ? [] : [options.executable],
|
||||
environment_names: options.environmentNames,
|
||||
origins: options.origins,
|
||||
network: options.network,
|
||||
mount: options.mount,
|
||||
operation_identity: `policy_explain:${capability}`,
|
||||
},
|
||||
options.write ? "write" : "read",
|
||||
);
|
||||
return evaluated.ok
|
||||
? { allowed: true, grant_id: evaluated.value.grant_id }
|
||||
: evaluated.error instanceof PermissionRequiredError
|
||||
? { allowed: false, error: projectAnalysisError(evaluated.error) }
|
||||
: { allowed: false, error: evaluated.error.message };
|
||||
};
|
||||
|
||||
const readProjectGrants = async (config: {
|
||||
readonly permissionProjectRoot: string | undefined;
|
||||
readonly permissionProjectStore: string | undefined;
|
||||
}) => {
|
||||
if (
|
||||
config.permissionProjectRoot === undefined ||
|
||||
config.permissionProjectStore === undefined
|
||||
)
|
||||
return {
|
||||
ok: false as const,
|
||||
error: { error: "Project policy is not configured" },
|
||||
};
|
||||
const store = await readProjectPermissionStore(
|
||||
config.permissionProjectStore,
|
||||
config.permissionProjectRoot,
|
||||
);
|
||||
return store.ok
|
||||
? {
|
||||
ok: true as const,
|
||||
grants: store.value?.grants ?? [],
|
||||
path: config.permissionProjectStore,
|
||||
root: config.permissionProjectRoot,
|
||||
}
|
||||
: { ok: false as const, error: { error: store.error.message } };
|
||||
};
|
||||
|
||||
const failure = (error: Parameters<typeof projectAnalysisError>[0]) => ({
|
||||
error: "Policy command failed",
|
||||
...projectAnalysisError(error),
|
||||
});
|
||||
@@ -1,12 +1,26 @@
|
||||
import { Cli, z } from "incur";
|
||||
import { Cli, Errors, z } from "incur";
|
||||
|
||||
import {
|
||||
captureProcessScenarioFile,
|
||||
compareProcessEvidenceFiles,
|
||||
isProcessCliErrorOutput,
|
||||
} from "./application/ProcessCli.js";
|
||||
import { logCliCommand } from "./cliLogging.js";
|
||||
import type { Logger } from "./logger.js";
|
||||
|
||||
const requireProcessCommandSuccess = async <Value>(
|
||||
execute: () => Promise<Value>,
|
||||
): Promise<Value> => {
|
||||
const value = await execute();
|
||||
if (isProcessCliErrorOutput(value))
|
||||
throw new Errors.IncurError({
|
||||
code: "PROCESS_COMMAND_FAILED",
|
||||
message: value.message,
|
||||
retryable: false,
|
||||
});
|
||||
return value;
|
||||
};
|
||||
|
||||
/** Register Process Capture v4 one-shot commands through shared application services. */
|
||||
export const registerProcessCommands = (
|
||||
cli: ReturnType<typeof Cli.create>,
|
||||
@@ -17,7 +31,9 @@ export const registerProcessCommands = (
|
||||
args: z.object({ scenario: z.string().describe("Scenario JSON path") }),
|
||||
run: ({ args }) =>
|
||||
logCliCommand(logger, "capture-process", () =>
|
||||
captureProcessScenarioFile(args.scenario),
|
||||
requireProcessCommandSuccess(() =>
|
||||
captureProcessScenarioFile(args.scenario),
|
||||
),
|
||||
),
|
||||
});
|
||||
cli.command("compare-process-captures", {
|
||||
@@ -28,7 +44,9 @@ export const registerProcessCommands = (
|
||||
}),
|
||||
run: ({ args }) =>
|
||||
logCliCommand(logger, "compare-process-captures", () =>
|
||||
compareProcessEvidenceFiles(args.left, args.right),
|
||||
requireProcessCommandSuccess(() =>
|
||||
compareProcessEvidenceFiles(args.left, args.right),
|
||||
),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import { Cli, z } from "incur";
|
||||
|
||||
import type { UnknownRegistry } from "./application/UnknownRegistry.js";
|
||||
import type { Logger } from "./logger.js";
|
||||
import type {
|
||||
RecordUnknownInput,
|
||||
UpdateUnknownInput,
|
||||
} from "./domain/residualUnknown.js";
|
||||
|
||||
/** Register residual-unknown registry CLI commands. */
|
||||
export const registerUnknownCommands = (
|
||||
cli: ReturnType<typeof Cli.create>,
|
||||
_logger: Logger,
|
||||
registry: UnknownRegistry,
|
||||
): void => {
|
||||
cli.command("unknowns", {
|
||||
description: "List recorded residual unknowns with optional filters",
|
||||
options: z.object({
|
||||
status: z
|
||||
.enum(["open", "investigating", "blocked", "contradicted", "resolved"])
|
||||
.optional()
|
||||
.describe("Filter by status"),
|
||||
severity: z
|
||||
.enum(["low", "medium", "high", "critical"])
|
||||
.optional()
|
||||
.describe("Filter by severity"),
|
||||
domain: z.string().optional().describe("Filter by domain"),
|
||||
}),
|
||||
run: ({ options }): ReturnType<typeof registry.listUnknowns> => {
|
||||
const filters: {
|
||||
status?:
|
||||
| "open"
|
||||
| "investigating"
|
||||
| "blocked"
|
||||
| "contradicted"
|
||||
| "resolved";
|
||||
severity?: "low" | "medium" | "high" | "critical";
|
||||
domain?: string;
|
||||
} = {};
|
||||
if (options.status !== undefined) filters.status = options.status;
|
||||
if (options.severity !== undefined) filters.severity = options.severity;
|
||||
if (options.domain !== undefined) filters.domain = options.domain;
|
||||
return registry.listUnknowns(filters);
|
||||
},
|
||||
});
|
||||
|
||||
cli.command("unknown-record", {
|
||||
description: "Record one approved residual unknown question",
|
||||
options: z.object({
|
||||
question: z.string().min(1).max(1_000).describe("The bounded question"),
|
||||
severity: z
|
||||
.enum(["low", "medium", "high", "critical"])
|
||||
.describe("Severity of the unknown"),
|
||||
domain: z.string().min(1).max(100).describe("Domain of the unknown"),
|
||||
authority: z
|
||||
.enum([
|
||||
"shipped-artifact",
|
||||
"controlled-replay",
|
||||
"historical-reference",
|
||||
"external-service",
|
||||
"analyst-inference",
|
||||
])
|
||||
.describe("Required authority to resolve"),
|
||||
confidence: z
|
||||
.enum(["observed", "derived", "inferred"])
|
||||
.describe("Required confidence level"),
|
||||
evidence: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("Mutation evidence ID for this record operation"),
|
||||
}),
|
||||
run: ({ options }) =>
|
||||
registry.recordUnknown(
|
||||
{
|
||||
question: options.question,
|
||||
severity: options.severity,
|
||||
domain: options.domain,
|
||||
required_authority: options.authority,
|
||||
required_confidence: options.confidence,
|
||||
required_environment: null,
|
||||
supporting_evidence_ids: [],
|
||||
contradicting_evidence_ids: [],
|
||||
recommended_probes: [],
|
||||
relationships: [],
|
||||
approved: true,
|
||||
} as RecordUnknownInput,
|
||||
options.evidence,
|
||||
),
|
||||
});
|
||||
|
||||
cli.command("unknown-update", {
|
||||
description: "Update one residual unknown with new evidence or status",
|
||||
options: z.object({
|
||||
id: z.string().describe("Unknown ID (unk_…)"),
|
||||
revision: z.number().int().min(1).describe("Expected current revision"),
|
||||
status: z
|
||||
.enum(["open", "investigating", "blocked", "contradicted", "resolved"])
|
||||
.describe("New status"),
|
||||
severity: z
|
||||
.enum(["low", "medium", "high", "critical"])
|
||||
.describe("New severity"),
|
||||
evidence: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("Mutation evidence ID for this update operation"),
|
||||
}),
|
||||
run: ({ options }) =>
|
||||
registry.updateUnknown(
|
||||
{
|
||||
unknown_id: options.id,
|
||||
expected_revision: options.revision,
|
||||
approved: true,
|
||||
status: options.status,
|
||||
severity: options.severity,
|
||||
required_authority: null,
|
||||
required_confidence: "observed",
|
||||
required_environment: null,
|
||||
supporting_evidence_ids: [],
|
||||
contradicting_evidence_ids: [],
|
||||
recommended_probes: [],
|
||||
relationships: [],
|
||||
resolution: null,
|
||||
} as unknown as UpdateUnknownInput,
|
||||
options.evidence,
|
||||
),
|
||||
});
|
||||
|
||||
cli.command("unknown-verify", {
|
||||
description: "Verify the resolution state of one residual unknown",
|
||||
options: z.object({
|
||||
id: z.string().describe("Unknown ID (unk_…)"),
|
||||
}),
|
||||
run: ({ options }) => registry.verifyUnknownResolution(options.id),
|
||||
});
|
||||
};
|
||||
+22
-177
@@ -6,14 +6,6 @@ import type { LogLevel } from "./logger.js";
|
||||
import type { ProcessExecutionPolicy } from "./domain/processCapture.js";
|
||||
import type { EvidenceFilePolicy } from "./domain/evidenceBundle.js";
|
||||
import type { ReferenceSourcePolicy } from "./domain/referenceSourcePolicy.js";
|
||||
import type {
|
||||
PermissionCeiling,
|
||||
PermissionGrant,
|
||||
} from "./domain/permissionPolicy.js";
|
||||
import {
|
||||
browserEndpointSchema,
|
||||
browserOriginSchema,
|
||||
} from "./domain/browserObservation.js";
|
||||
|
||||
const DEFAULT_HOPPER_LAUNCHER_PATH =
|
||||
"/Applications/Hopper Disassembler.app/Contents/MacOS/hopper";
|
||||
@@ -30,64 +22,34 @@ export interface AppConfig {
|
||||
readonly logLevel: LogLevel;
|
||||
readonly processExecutionPolicy: ProcessExecutionPolicy;
|
||||
readonly artifactNativeMountEnabled: boolean;
|
||||
readonly artifactIntegrityContinueEnabled: boolean;
|
||||
readonly evidenceFilePolicy: EvidenceFilePolicy;
|
||||
readonly investigationInputRoots: readonly string[];
|
||||
readonly analysisSnapshotFilePolicy: EvidenceFilePolicy;
|
||||
readonly referenceSourcePolicy: ReferenceSourcePolicy;
|
||||
readonly browserObservationEnabled: boolean;
|
||||
readonly browserCdpEndpoints: readonly string[];
|
||||
readonly browserAllowedOrigins: readonly string[];
|
||||
readonly permissionCeilings: readonly PermissionCeiling[];
|
||||
readonly administratorPermissionGrants: readonly PermissionGrant[];
|
||||
readonly permissionProjectRoot: string | undefined;
|
||||
readonly permissionProjectStore: string | undefined;
|
||||
}
|
||||
|
||||
const environmentSchema = z
|
||||
.object({
|
||||
HOPPER_LAUNCHER_PATH: z.string().min(1).optional(),
|
||||
HOPPER_TARGET_PATH: z.string().min(1).optional(),
|
||||
HOPPER_TARGET_KIND: z
|
||||
.enum(["executable", "database"])
|
||||
.default("executable"),
|
||||
HOPPER_LOADER_ARGS_JSON: z.string().optional(),
|
||||
REA_LOG_LEVEL: z
|
||||
.enum(["trace", "debug", "info", "warn", "error", "fatal", "silent"])
|
||||
.default("info"),
|
||||
REA_PROCESS_CAPTURE_ENABLED: z.enum(["true", "false"]).default("false"),
|
||||
REA_ARTIFACT_NATIVE_MOUNT_ENABLED: z
|
||||
.enum(["true", "false"])
|
||||
.default("false"),
|
||||
REA_ARTIFACT_INTEGRITY_CONTINUE_ENABLED: z
|
||||
.enum(["true", "false"])
|
||||
.default("false"),
|
||||
REA_PROCESS_ALLOW_EXTERNAL_NETWORK: z
|
||||
.enum(["true", "false"])
|
||||
.default("false"),
|
||||
REA_PROCESS_EXECUTABLE_ROOTS_JSON: z.string().default("[]"),
|
||||
REA_PROCESS_WORKING_ROOTS_JSON: z.string().default("[]"),
|
||||
REA_PROCESS_ALLOWED_ENV_JSON: z.string().default("[]"),
|
||||
REA_EVIDENCE_ROOTS_JSON: z.string().default("[]"),
|
||||
REA_INVESTIGATION_INPUT_ROOTS_JSON: z.string().default("[]"),
|
||||
REA_ANALYSIS_SNAPSHOT_ROOTS_JSON: z.string().default("[]"),
|
||||
REA_REFERENCE_ROOTS_JSON: z.string().default("[]"),
|
||||
REA_REFERENCE_SECRET_PATTERNS_JSON: z.string().default("[]"),
|
||||
REA_BROWSER_OBSERVE_ENABLED: z.enum(["true", "false"]).default("false"),
|
||||
REA_BROWSER_CDP_ENDPOINTS_JSON: z.string().default("[]"),
|
||||
REA_BROWSER_ALLOWED_ORIGINS_JSON: z.string().default("[]"),
|
||||
REA_PERMISSION_PROJECT_ROOT: z.string().min(1).optional(),
|
||||
REA_PERMISSION_PROJECT_STORE: z.string().min(1).optional(),
|
||||
})
|
||||
.refine(
|
||||
(value) =>
|
||||
(value.REA_PERMISSION_PROJECT_ROOT === undefined) ===
|
||||
(value.REA_PERMISSION_PROJECT_STORE === undefined),
|
||||
{
|
||||
message:
|
||||
"REA_PERMISSION_PROJECT_ROOT and REA_PERMISSION_PROJECT_STORE must be configured together",
|
||||
},
|
||||
);
|
||||
const environmentSchema = z.object({
|
||||
HOPPER_LAUNCHER_PATH: z.string().min(1).optional(),
|
||||
HOPPER_TARGET_PATH: z.string().min(1).optional(),
|
||||
HOPPER_TARGET_KIND: z.enum(["executable", "database"]).default("executable"),
|
||||
HOPPER_LOADER_ARGS_JSON: z.string().optional(),
|
||||
REA_LOG_LEVEL: z
|
||||
.enum(["trace", "debug", "info", "warn", "error", "fatal", "silent"])
|
||||
.default("info"),
|
||||
REA_PROCESS_CAPTURE_ENABLED: z.enum(["true", "false"]).default("false"),
|
||||
REA_ARTIFACT_NATIVE_MOUNT_ENABLED: z.enum(["true", "false"]).default("false"),
|
||||
REA_PROCESS_ALLOW_EXTERNAL_NETWORK: z
|
||||
.enum(["true", "false"])
|
||||
.default("false"),
|
||||
REA_PROCESS_EXECUTABLE_ROOTS_JSON: z.string().default("[]"),
|
||||
REA_PROCESS_WORKING_ROOTS_JSON: z.string().default("[]"),
|
||||
REA_PROCESS_ALLOWED_ENV_JSON: z.string().default("[]"),
|
||||
REA_EVIDENCE_ROOTS_JSON: z.string().default("[]"),
|
||||
REA_INVESTIGATION_INPUT_ROOTS_JSON: z.string().default("[]"),
|
||||
REA_ANALYSIS_SNAPSHOT_ROOTS_JSON: z.string().default("[]"),
|
||||
REA_REFERENCE_ROOTS_JSON: z.string().default("[]"),
|
||||
REA_REFERENCE_SECRET_PATTERNS_JSON: z.string().default("[]"),
|
||||
});
|
||||
|
||||
const parseStringArray = (
|
||||
encoded: string,
|
||||
@@ -110,29 +72,6 @@ const parseStringArray = (
|
||||
}
|
||||
};
|
||||
|
||||
const parseBrowserArray = (
|
||||
encoded: string,
|
||||
name: string,
|
||||
itemSchema: z.ZodType<string>,
|
||||
maximum: number,
|
||||
): Result<readonly string[], ConfigurationError> => {
|
||||
try {
|
||||
const parsed = z
|
||||
.array(itemSchema)
|
||||
.max(maximum)
|
||||
.safeParse(JSON.parse(encoded));
|
||||
return parsed.success
|
||||
? ok([...new Set(parsed.data)].sort())
|
||||
: err(
|
||||
new ConfigurationError(`${name} must encode valid browser scopes`, {
|
||||
cause: parsed.error,
|
||||
}),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return err(new ConfigurationError(`${name} must be valid JSON`, { cause }));
|
||||
}
|
||||
};
|
||||
|
||||
const filePolicy = (roots: readonly string[]): EvidenceFilePolicy => ({
|
||||
roots,
|
||||
maxBytes: 64 * 1024 * 1024,
|
||||
@@ -141,31 +80,6 @@ const filePolicy = (roots: readonly string[]): EvidenceFilePolicy => ({
|
||||
maxNodes: 1_000_000,
|
||||
});
|
||||
|
||||
const permissionScope = (
|
||||
capability: PermissionCeiling["capability"],
|
||||
roots: readonly string[],
|
||||
options: Partial<Omit<PermissionCeiling, "capability" | "roots">> = {},
|
||||
): PermissionCeiling => ({
|
||||
capability,
|
||||
roots,
|
||||
executables: options.executables ?? [],
|
||||
environment_names: options.environment_names ?? [],
|
||||
...(options.origins === undefined ? {} : { origins: options.origins }),
|
||||
network: options.network ?? "none",
|
||||
mount: options.mount ?? false,
|
||||
});
|
||||
|
||||
const administratorGrants = (
|
||||
ceilings: readonly PermissionCeiling[],
|
||||
): readonly PermissionGrant[] =>
|
||||
ceilings.map((ceiling) => ({
|
||||
...ceiling,
|
||||
grant_id: `administrator:${ceiling.capability}`,
|
||||
lifetime: "administrator",
|
||||
operation_identity: null,
|
||||
expires_at: null,
|
||||
}));
|
||||
|
||||
const parseLoaderArgs = (
|
||||
encoded: string | undefined,
|
||||
): Result<readonly string[], ConfigurationError> => {
|
||||
@@ -252,55 +166,6 @@ export const parseConfig = (
|
||||
"REA_REFERENCE_SECRET_PATTERNS_JSON",
|
||||
);
|
||||
if (!secretPatterns.ok) return secretPatterns;
|
||||
const browserEndpoints = parseBrowserArray(
|
||||
parsedEnvironment.data.REA_BROWSER_CDP_ENDPOINTS_JSON,
|
||||
"REA_BROWSER_CDP_ENDPOINTS_JSON",
|
||||
browserEndpointSchema,
|
||||
16,
|
||||
);
|
||||
if (!browserEndpoints.ok) return browserEndpoints;
|
||||
const browserOrigins = parseBrowserArray(
|
||||
parsedEnvironment.data.REA_BROWSER_ALLOWED_ORIGINS_JSON,
|
||||
"REA_BROWSER_ALLOWED_ORIGINS_JSON",
|
||||
browserOriginSchema,
|
||||
32,
|
||||
);
|
||||
if (!browserOrigins.ok) return browserOrigins;
|
||||
const permissionCeilings = [
|
||||
...(parsedEnvironment.data.REA_PROCESS_CAPTURE_ENABLED === "true"
|
||||
? [
|
||||
permissionScope("process_capture", workingRoots.value, {
|
||||
executables: executableRoots.value,
|
||||
environment_names: allowedEnvironment.value,
|
||||
network:
|
||||
parsedEnvironment.data.REA_PROCESS_ALLOW_EXTERNAL_NETWORK ===
|
||||
"true"
|
||||
? "external"
|
||||
: "none",
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
permissionScope("evidence_read", evidenceRoots.value),
|
||||
permissionScope("evidence_write", evidenceRoots.value),
|
||||
permissionScope("investigation_input", investigationInputRoots.value),
|
||||
permissionScope("investigation_workspace_read", evidenceRoots.value),
|
||||
permissionScope("investigation_workspace_write", evidenceRoots.value),
|
||||
permissionScope("snapshot_read", analysisSnapshotRoots.value),
|
||||
permissionScope("snapshot_write", analysisSnapshotRoots.value),
|
||||
permissionScope("artifact_extract", ["/"]),
|
||||
permissionScope("reference_read", referenceRoots.value),
|
||||
...(parsedEnvironment.data.REA_BROWSER_OBSERVE_ENABLED === "true"
|
||||
? [
|
||||
permissionScope("browser_observe", [], {
|
||||
origins: [...browserEndpoints.value, ...browserOrigins.value],
|
||||
network: browserNetworkScope(browserOrigins.value),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...(parsedEnvironment.data.REA_ARTIFACT_NATIVE_MOUNT_ENABLED === "true"
|
||||
? [permissionScope("native_mount", [], { mount: true })]
|
||||
: []),
|
||||
] satisfies readonly PermissionCeiling[];
|
||||
return ok({
|
||||
hopperLauncherPath:
|
||||
parsedEnvironment.data.HOPPER_LAUNCHER_PATH ??
|
||||
@@ -319,8 +184,6 @@ export const parseConfig = (
|
||||
},
|
||||
artifactNativeMountEnabled:
|
||||
parsedEnvironment.data.REA_ARTIFACT_NATIVE_MOUNT_ENABLED === "true",
|
||||
artifactIntegrityContinueEnabled:
|
||||
parsedEnvironment.data.REA_ARTIFACT_INTEGRITY_CONTINUE_ENABLED === "true",
|
||||
evidenceFilePolicy: filePolicy(evidenceRoots.value),
|
||||
investigationInputRoots: investigationInputRoots.value,
|
||||
analysisSnapshotFilePolicy: filePolicy(analysisSnapshotRoots.value),
|
||||
@@ -332,23 +195,5 @@ export const parseConfig = (
|
||||
maxDepth: 32,
|
||||
maxPathBytes: 4_096,
|
||||
},
|
||||
browserObservationEnabled:
|
||||
parsedEnvironment.data.REA_BROWSER_OBSERVE_ENABLED === "true",
|
||||
browserCdpEndpoints: browserEndpoints.value,
|
||||
browserAllowedOrigins: browserOrigins.value,
|
||||
permissionCeilings,
|
||||
administratorPermissionGrants: administratorGrants(permissionCeilings),
|
||||
permissionProjectRoot: parsedEnvironment.data.REA_PERMISSION_PROJECT_ROOT,
|
||||
permissionProjectStore: parsedEnvironment.data.REA_PERMISSION_PROJECT_STORE,
|
||||
});
|
||||
};
|
||||
|
||||
const browserNetworkScope = (
|
||||
origins: readonly string[],
|
||||
): "loopback" | "external" =>
|
||||
origins.every((origin) => {
|
||||
const hostname = new URL(origin).hostname;
|
||||
return hostname === "127.0.0.1" || hostname === "[::1]";
|
||||
})
|
||||
? "loopback"
|
||||
: "external";
|
||||
|
||||
@@ -33,26 +33,11 @@ const traversalLimitsInput = {
|
||||
};
|
||||
|
||||
/** Exact caller boundary for deterministic artifact inventory. */
|
||||
export const artifactInventoryInputSchema = z
|
||||
.object({
|
||||
native_mount_approved: z.boolean().default(false),
|
||||
integrity_policy: z.enum(["fail", "record-and-continue"]).default("fail"),
|
||||
integrity_continue_approved: z.boolean().default(false),
|
||||
max_integrity_mismatches: z.number().int().min(1).max(100).default(10),
|
||||
...pageInput,
|
||||
...traversalLimitsInput,
|
||||
})
|
||||
.superRefine((input, context) => {
|
||||
if (
|
||||
input.integrity_policy === "record-and-continue" &&
|
||||
input.integrity_continue_approved !== true
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["integrity_continue_approved"],
|
||||
message: "record-and-continue requires explicit approval",
|
||||
});
|
||||
});
|
||||
export const artifactInventoryInputSchema = z.object({
|
||||
native_mount_approved: z.boolean().default(false),
|
||||
...pageInput,
|
||||
...traversalLimitsInput,
|
||||
});
|
||||
|
||||
/** Exact caller boundary for approved artifact extraction. */
|
||||
export const artifactExtractionInputSchema = z.object({
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import type { ToolContract } from "./toolContracts.js";
|
||||
import { evidenceSchema } from "../domain/evidence.js";
|
||||
import {
|
||||
browserTargetListSchema,
|
||||
inspectWebPageInputSchema,
|
||||
listBrowserTargetsInputSchema,
|
||||
webPageInspectionSchema,
|
||||
} from "../domain/browserObservation.js";
|
||||
|
||||
const evidenceResult = (schema: typeof browserTargetListSchema) =>
|
||||
evidenceSchema
|
||||
.omit({ normalized_result: true })
|
||||
.extend({ normalized_result: schema });
|
||||
const listOutputSchema = evidenceResult(browserTargetListSchema);
|
||||
const inspectionOutputSchema = evidenceSchema
|
||||
.omit({ normalized_result: true })
|
||||
.extend({ normalized_result: webPageInspectionSchema });
|
||||
|
||||
const endpoint = "http://127.0.0.1:9222";
|
||||
const origin = "https://app.example.test";
|
||||
|
||||
/** Origin-scoped, passive browser reverse-engineering contracts. */
|
||||
export const BROWSER_TOOL_CONTRACTS = [
|
||||
{
|
||||
name: "list_browser_targets",
|
||||
description:
|
||||
"List bounded page targets from an approved user-owned loopback Chrome DevTools Protocol endpoint. Only targets whose current URL matches an approved exact origin are returned; URL credentials, query values, and fragments are redacted.",
|
||||
kind: "browser-provider",
|
||||
inputSchema: listBrowserTargetsInputSchema,
|
||||
outputSchema: listOutputSchema,
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: true,
|
||||
},
|
||||
examples: [
|
||||
{
|
||||
title: "List approved browser page targets",
|
||||
input: {
|
||||
cdp_endpoint: endpoint,
|
||||
allowed_origins: [origin],
|
||||
approved: true,
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "inspect_web_page",
|
||||
description:
|
||||
"Passively inspect one approved page target through CDP without evaluating JavaScript, navigating, clicking, closing, or mutating the page. Returns bounded DOM structure, accessibility, scripts, resources, attach-window network and console metadata, workers, and redacted storage inventory as Evidence v2.",
|
||||
kind: "browser-provider",
|
||||
inputSchema: inspectWebPageInputSchema,
|
||||
outputSchema: inspectionOutputSchema,
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: true,
|
||||
},
|
||||
examples: [
|
||||
{
|
||||
title: "Inspect one approved browser page",
|
||||
input: {
|
||||
cdp_endpoint: endpoint,
|
||||
allowed_origins: [origin],
|
||||
approved: true,
|
||||
target_id: "TARGET_ID_FROM_LIST_BROWSER_TARGETS",
|
||||
observation_ms: 500,
|
||||
include_script_sources: false,
|
||||
include_storage_keys: false,
|
||||
limits: {
|
||||
max_frames: 200,
|
||||
max_dom_nodes: 2_000,
|
||||
max_ax_nodes: 2_000,
|
||||
max_scripts: 200,
|
||||
max_resources: 2_000,
|
||||
max_workers: 500,
|
||||
max_storage_keys: 1_000,
|
||||
max_script_source_bytes: 1_048_576,
|
||||
max_total_script_source_bytes: 4_194_304,
|
||||
max_network_events: 1_000,
|
||||
max_console_events: 200,
|
||||
max_websocket_events: 500,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const satisfies readonly ToolContract[];
|
||||
@@ -1,107 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const categorySchema = z.enum([
|
||||
"invalid_input",
|
||||
"permission_required",
|
||||
"unsupported_provider",
|
||||
"integrity_mismatch",
|
||||
"truncated",
|
||||
"cancelled",
|
||||
"timeout",
|
||||
"unavailable",
|
||||
"execution_failure",
|
||||
]);
|
||||
const remediationSchema = z
|
||||
.object({
|
||||
action: z.string().min(1),
|
||||
restart_required: z.boolean(),
|
||||
elicitation_supported: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
const common = {
|
||||
category: categorySchema,
|
||||
message: z.string().min(1),
|
||||
retryable: z.boolean(),
|
||||
remediation: remediationSchema,
|
||||
};
|
||||
const genericDetails = z.record(z.string(), z.json()).optional();
|
||||
const generic = <Code extends string>(code: Code) =>
|
||||
z
|
||||
.object({ code: z.literal(code), ...common, details: genericDetails })
|
||||
.strict();
|
||||
|
||||
const scopeSchema = z
|
||||
.object({
|
||||
capability: z.string(),
|
||||
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(),
|
||||
})
|
||||
.strict();
|
||||
const missingScopeSchema = z
|
||||
.object({
|
||||
roots: z.array(z.string()).optional(),
|
||||
executables: z.array(z.string()).optional(),
|
||||
environment_names: z.array(z.string()).optional(),
|
||||
origins: z.array(z.string()).optional(),
|
||||
network: z.enum(["none", "loopback", "external"]).optional(),
|
||||
mount: z.literal(true).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Stable discriminated schema shared by every CLI and MCP error surface. */
|
||||
export const analysisErrorProjectionSchema = z.discriminatedUnion("code", [
|
||||
generic("invalid_request"),
|
||||
generic("unreadable_output"),
|
||||
generic("capability_unavailable"),
|
||||
generic("provider_unavailable"),
|
||||
generic("provider_timeout"),
|
||||
generic("cancelled"),
|
||||
z
|
||||
.object({
|
||||
code: z.literal("artifact_integrity_mismatch"),
|
||||
...common,
|
||||
details: z
|
||||
.object({
|
||||
logical_path: z.string(),
|
||||
declared_sha256: z.string().nullable(),
|
||||
calculated_sha256: z.string().nullable(),
|
||||
unpacked: z.boolean(),
|
||||
})
|
||||
.strict(),
|
||||
})
|
||||
.strict(),
|
||||
generic("artifact_operation_failed"),
|
||||
generic("evidence_integrity_mismatch"),
|
||||
generic("truncated"),
|
||||
z
|
||||
.object({
|
||||
code: z.literal("permission_required"),
|
||||
...common,
|
||||
details: z
|
||||
.object({
|
||||
capability: z.string(),
|
||||
requested: scopeSchema,
|
||||
missing: missingScopeSchema,
|
||||
ceiling: scopeSchema.nullable(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
})
|
||||
.strict(),
|
||||
generic("process_capture_failed"),
|
||||
generic("cleanup_incomplete"),
|
||||
generic("revision_conflict"),
|
||||
generic("outside_approved_root"),
|
||||
generic("configuration_invalid"),
|
||||
generic("target_unavailable"),
|
||||
generic("execution_failure"),
|
||||
]);
|
||||
|
||||
/** JSON Schema document used by generated API documentation and clients. */
|
||||
export const analysisErrorJsonSchema = z.toJSONSchema(
|
||||
analysisErrorProjectionSchema,
|
||||
);
|
||||
@@ -22,7 +22,6 @@ export type ToolKind =
|
||||
| "enhanced"
|
||||
| "native-provider"
|
||||
| "artifact-provider"
|
||||
| "browser-provider"
|
||||
| "session";
|
||||
|
||||
/** Single source of truth for a public MCP tool. */
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
import { TOOL_EXAMPLE_OVERRIDES } from "./toolContractExamples.js";
|
||||
import { NATIVE_TOOL_CONTRACTS } from "./nativeToolContracts.js";
|
||||
import { ARTIFACT_TOOL_CONTRACTS } from "./artifactToolContracts.js";
|
||||
import { BROWSER_TOOL_CONTRACTS } from "./browserToolContracts.js";
|
||||
import {
|
||||
closeBinaryInputSchema,
|
||||
openBinaryInputSchema,
|
||||
@@ -390,16 +389,6 @@ export const ENHANCED_TOOL_CONTRACTS = [
|
||||
),
|
||||
] as const satisfies readonly ToolContract[];
|
||||
|
||||
/** Caller observations used to compare a live server with expected identity. */
|
||||
export const binarySessionInputSchema = z.object({
|
||||
expected_package_version: z.string().min(1).optional(),
|
||||
expected_catalog_digest: z
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{64}$/u)
|
||||
.optional(),
|
||||
expected_server_path: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
/** Target lifecycle tools available only on the long-lived MCP adapter. */
|
||||
export const SESSION_TOOL_CONTRACTS = [
|
||||
session(
|
||||
@@ -415,7 +404,7 @@ export const SESSION_TOOL_CONTRACTS = [
|
||||
session(
|
||||
"binary_session",
|
||||
"Report provider identity, deterministic capability descriptors, and whether a target is open; open targets include canonical path, format, and kind. Use availability, effects, limits, and limitations before selecting analysis operations.",
|
||||
binarySessionInputSchema,
|
||||
z.object({}),
|
||||
),
|
||||
session(
|
||||
"export_evidence_bundle",
|
||||
@@ -513,12 +502,15 @@ export const SESSION_TOOL_CONTRACTS = [
|
||||
),
|
||||
] as const satisfies readonly ToolContract[];
|
||||
|
||||
/** Complete ordered public inventory used by registration and verification. */
|
||||
/**
|
||||
* Complete ordered public inventory used by registration and verification.
|
||||
* Keep this collection at 68 tools unless a deliberate contract change updates
|
||||
* snapshots, package verification, and real-Hopper verification together.
|
||||
*/
|
||||
export const TOOL_CONTRACTS = [
|
||||
...OFFICIAL_TOOL_CONTRACTS,
|
||||
...ENHANCED_TOOL_CONTRACTS,
|
||||
...NATIVE_TOOL_CONTRACTS,
|
||||
...ARTIFACT_TOOL_CONTRACTS,
|
||||
...BROWSER_TOOL_CONTRACTS,
|
||||
...SESSION_TOOL_CONTRACTS,
|
||||
] as const;
|
||||
|
||||
@@ -25,7 +25,6 @@ import { changedBehaviorResultSchema } from "../domain/changedBehavior.js";
|
||||
import { callPathResultSchema } from "../domain/callPath.js";
|
||||
import { staticRuntimeCorrelationResultSchema } from "../domain/staticRuntimeCorrelation.js";
|
||||
import { reconstructionVerificationResultSchema } from "../domain/reconstructionVerification.js";
|
||||
import { analysisErrorProjectionSchema } from "./errorSchemas.js";
|
||||
|
||||
const resultOf = (schema: z.ZodType) =>
|
||||
evidenceSchema
|
||||
@@ -97,69 +96,6 @@ const sessionProvider = z.object({
|
||||
provider: providerIdentity,
|
||||
providers: z.array(providerIdentity),
|
||||
capabilities: z.array(providerCapability),
|
||||
tool_availability: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
surface: z.string(),
|
||||
available: z.boolean(),
|
||||
reason: z.enum([
|
||||
"available",
|
||||
"target_required",
|
||||
"provider_missing",
|
||||
"provider_unavailable",
|
||||
"target_unsupported",
|
||||
"unsupported_host",
|
||||
"policy_disabled",
|
||||
]),
|
||||
remediation: z.string().nullable(),
|
||||
annotations: z.object({
|
||||
read_only: z.boolean(),
|
||||
destructive: z.boolean(),
|
||||
idempotent: z.boolean(),
|
||||
open_world: z.boolean(),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
client_features: z.object({
|
||||
elicitation_form: z.boolean(),
|
||||
elicitation_url: z.boolean(),
|
||||
roots: z.boolean(),
|
||||
sampling: z.boolean(),
|
||||
}),
|
||||
server_identity: z.object({
|
||||
package: z.object({
|
||||
name: z.string(),
|
||||
version: z.string(),
|
||||
root_path: z.string(),
|
||||
build_commit: z.string().nullable(),
|
||||
}),
|
||||
server: z.object({
|
||||
name: z.string(),
|
||||
version: z.string(),
|
||||
started_at: z.string(),
|
||||
command_path: z.string(),
|
||||
}),
|
||||
sdk: z.object({
|
||||
server: z.string(),
|
||||
client_test: z.string(),
|
||||
core: z.string(),
|
||||
}),
|
||||
negotiated_protocol_version: z.string().nullable(),
|
||||
client: z.object({ name: z.string(), version: z.string() }).nullable(),
|
||||
skill: z.object({ name: z.string(), expected_version: z.string() }),
|
||||
catalog: z.record(z.string(), z.json()),
|
||||
protocol_features: z.object({
|
||||
progress: z.boolean(),
|
||||
cancellation: z.boolean(),
|
||||
evidence_resources: z.boolean(),
|
||||
elicitation: z.boolean(),
|
||||
}),
|
||||
alignment: z.object({
|
||||
state: z.enum(["aligned", "mcp_server_restart_required", "unknown"]),
|
||||
reasons: z.array(z.string()),
|
||||
remediation: z.string().nullable(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
const nullableText = z.string().nullable();
|
||||
const addressList = z.array(z.string());
|
||||
@@ -241,6 +177,45 @@ const symbolDiscoveryOutput = (property: "classes" | "protocols") =>
|
||||
[property]: z.array(addressedEntry),
|
||||
}),
|
||||
);
|
||||
const analysisErrorProjectionSchema = z
|
||||
.object({
|
||||
category: z.enum([
|
||||
"invalid_input",
|
||||
"permission_required",
|
||||
"unsupported_provider",
|
||||
"integrity_mismatch",
|
||||
"truncated",
|
||||
"cancelled",
|
||||
"timeout",
|
||||
"unavailable",
|
||||
"execution_failure",
|
||||
]),
|
||||
message: z.string(),
|
||||
details: z
|
||||
.object({
|
||||
logical_path: z.string(),
|
||||
declared_sha256: z.string().nullable(),
|
||||
calculated_sha256: z.string().nullable(),
|
||||
unpacked: z.boolean(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
code: z
|
||||
.enum([
|
||||
"private_display_unavailable",
|
||||
"x11_authorization_failed",
|
||||
"unsupported_hopper_build",
|
||||
"invalid_launch_command",
|
||||
"process_ownership_mismatch",
|
||||
"hopper_exited_during_startup",
|
||||
"unsupported_demo_dialog",
|
||||
"unexpected_display_geometry",
|
||||
"x11_input_failed",
|
||||
"runtime_dependency_unavailable",
|
||||
])
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
const graphNode = z.discriminatedUnion("status", [
|
||||
z.object({
|
||||
address: z.string(),
|
||||
|
||||
@@ -20,15 +20,8 @@ const comparisonStatusSchema = z.enum([
|
||||
"changed",
|
||||
"truncated",
|
||||
"unknown",
|
||||
"contradiction",
|
||||
]);
|
||||
const changeKindSchema = z.enum([
|
||||
"added",
|
||||
"removed",
|
||||
"changed",
|
||||
"unknown",
|
||||
"contradiction",
|
||||
]);
|
||||
const changeKindSchema = z.enum(["added", "removed", "changed", "unknown"]);
|
||||
const comparisonDimensionSchema = z.enum([
|
||||
"content",
|
||||
"kind",
|
||||
@@ -38,7 +31,6 @@ const comparisonDimensionSchema = z.enum([
|
||||
"relations",
|
||||
"metadata",
|
||||
"availability",
|
||||
"integrity",
|
||||
]);
|
||||
|
||||
/** Strict Evidence-backed input for deterministic artifact comparison. */
|
||||
@@ -88,7 +80,6 @@ export const artifactComparisonResultSchema = z.object({
|
||||
removed: z.number().int().min(0),
|
||||
changed: z.number().int().min(0),
|
||||
unknown: z.number().int().min(0),
|
||||
contradiction: z.number().int().min(0).default(0),
|
||||
}),
|
||||
changes: z.object({
|
||||
items: z.array(artifactChangeSchema).max(500),
|
||||
@@ -116,10 +107,9 @@ export const compareArtifacts = (
|
||||
const right = parseArtifactInventoryEvidence(rightEvidence);
|
||||
const leftComplete = left.inventory.complete;
|
||||
const rightComplete = right.inventory.complete;
|
||||
const leftCovered =
|
||||
leftComplete && !hasCoverageLimitation(left.inventory.limitations);
|
||||
const leftCovered = leftComplete && left.inventory.limitations.length === 0;
|
||||
const rightCovered =
|
||||
rightComplete && !hasCoverageLimitation(right.inventory.limitations);
|
||||
rightComplete && right.inventory.limitations.length === 0;
|
||||
const limitations = [
|
||||
...left.inventory.limitations.map((item) => `Left: ${item}`),
|
||||
...right.inventory.limitations.map((item) => `Right: ${item}`),
|
||||
@@ -146,17 +136,13 @@ export const compareArtifacts = (
|
||||
status:
|
||||
!leftComplete || !rightComplete
|
||||
? "truncated"
|
||||
: changes.some(
|
||||
({ classification }) => classification === "contradiction",
|
||||
)
|
||||
? "contradiction"
|
||||
: !leftCovered || !rightCovered
|
||||
: !leftCovered || !rightCovered
|
||||
? "unknown"
|
||||
: changes.some(({ classification }) => classification === "unknown")
|
||||
? "unknown"
|
||||
: changes.some(({ classification }) => classification === "unknown")
|
||||
? "unknown"
|
||||
: changes.length === 0
|
||||
? "unchanged"
|
||||
: "changed",
|
||||
: changes.length === 0
|
||||
? "unchanged"
|
||||
: "changed",
|
||||
left_manifest_id: left.inventory.manifest.manifest_id,
|
||||
right_manifest_id: right.inventory.manifest.manifest_id,
|
||||
summary,
|
||||
@@ -244,15 +230,6 @@ const classifyPath = (input: {
|
||||
classification: input.complete ? "removed" : "unknown",
|
||||
dimensions: [input.complete ? "content" : "availability"],
|
||||
};
|
||||
if (
|
||||
input.leftOccurrence.hash_status === "mismatched" ||
|
||||
input.rightOccurrence.hash_status === "mismatched"
|
||||
)
|
||||
return {
|
||||
...base,
|
||||
classification: "contradiction",
|
||||
dimensions: ["integrity"],
|
||||
};
|
||||
if (
|
||||
input.leftOccurrence.hash_status !== "verified" ||
|
||||
input.rightOccurrence.hash_status !== "verified" ||
|
||||
@@ -385,13 +362,7 @@ const summarize = (
|
||||
changes: readonly ArtifactChange[],
|
||||
covered: boolean,
|
||||
) => {
|
||||
const counts = {
|
||||
added: 0,
|
||||
removed: 0,
|
||||
changed: 0,
|
||||
unknown: 0,
|
||||
contradiction: 0,
|
||||
};
|
||||
const counts = { added: 0, removed: 0, changed: 0, unknown: 0 };
|
||||
for (const change of changes) counts[change.classification] += 1;
|
||||
return {
|
||||
unchanged: covered
|
||||
@@ -399,7 +370,6 @@ const summarize = (
|
||||
0,
|
||||
Math.min(left.occurrences.length, right.occurrences.length) -
|
||||
counts.changed -
|
||||
counts.contradiction -
|
||||
counts.unknown,
|
||||
)
|
||||
: 0,
|
||||
@@ -414,14 +384,7 @@ const summarize = (
|
||||
) -
|
||||
counts.added -
|
||||
counts.removed -
|
||||
counts.changed -
|
||||
counts.contradiction,
|
||||
counts.changed,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const hasCoverageLimitation = (limitations: readonly string[]): boolean =>
|
||||
limitations.some(
|
||||
(limitation) =>
|
||||
!/integrity contradiction\(s\) were recorded/u.test(limitation),
|
||||
);
|
||||
|
||||
@@ -6,7 +6,6 @@ const occurrenceIdSchema = z.string().regex(/^occ_[a-f0-9]{64}$/u);
|
||||
const edgeIdSchema = z.string().regex(/^edge_[a-f0-9]{64}$/u);
|
||||
const manifestIdSchema = z.string().regex(/^agm_[a-f0-9]{64}$/u);
|
||||
const extractionIdSchema = z.string().regex(/^aex_[a-f0-9]{64}$/u);
|
||||
const contradictionIdSchema = z.string().regex(/^ic_[a-f0-9]{64}$/u);
|
||||
const boundedRelativePathSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
@@ -122,31 +121,11 @@ const artifactOccurrenceSchema = z.object({
|
||||
.nullable(),
|
||||
executable: z.boolean(),
|
||||
encrypted: z.boolean(),
|
||||
hash_status: z.enum([
|
||||
"verified",
|
||||
"mismatched",
|
||||
"not-hashed-limit",
|
||||
"unavailable",
|
||||
]),
|
||||
hash_status: z.enum(["verified", "not-hashed-limit", "unavailable"]),
|
||||
source_location: artifactByteRangeSchema.nullable(),
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
|
||||
/** Observed bytes that contradict a container's declared integrity identity. */
|
||||
const integrityContradictionSchema = z.object({
|
||||
contradiction_id: contradictionIdSchema,
|
||||
occurrence_id: occurrenceIdSchema,
|
||||
parent_artifact_id: artifactIdSchema,
|
||||
logical_path: boundedRelativePathSchema,
|
||||
declared_sha256: sha256Schema,
|
||||
observed_sha256: sha256Schema,
|
||||
entry_kind: z.enum(["file", "slice"]),
|
||||
unpacked: z.boolean(),
|
||||
trust: z.literal("observed-untrusted"),
|
||||
provenance: z.literal("container-integrity-metadata-versus-observed-bytes"),
|
||||
limitations: z.array(z.string()).min(1),
|
||||
});
|
||||
|
||||
/** Parent-child derivation relation between two content-addressed artifacts. */
|
||||
const artifactEdgeSchema = z.object({
|
||||
edge_id: edgeIdSchema,
|
||||
@@ -198,10 +177,6 @@ export const artifactInventoryResultSchema = z.object({
|
||||
edges: artifactPageSchema(artifactEdgeSchema),
|
||||
limits: artifactTraversalLimitsSchema,
|
||||
provenance: z.array(artifactCommandSchema).max(256),
|
||||
integrity_contradictions: z
|
||||
.array(integrityContradictionSchema)
|
||||
.max(100)
|
||||
.default([]),
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
|
||||
@@ -249,9 +224,6 @@ export type ArtifactEdge = z.infer<typeof artifactEdgeSchema>;
|
||||
|
||||
/** Parsed artifact occurrence. */
|
||||
export type ArtifactOccurrence = z.infer<typeof artifactOccurrenceSchema>;
|
||||
export type IntegrityContradiction = z.infer<
|
||||
typeof integrityContradictionSchema
|
||||
>;
|
||||
|
||||
/** Parsed deterministic artifact graph manifest. */
|
||||
export type ArtifactGraphManifest = z.infer<typeof artifactGraphManifestSchema>;
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
type ArtifactInventoryResult,
|
||||
type ArtifactNode,
|
||||
type ArtifactOccurrence,
|
||||
type IntegrityContradiction,
|
||||
} from "./artifactGraph.js";
|
||||
import { parseEvidence, type Evidence } from "./evidence.js";
|
||||
|
||||
@@ -17,7 +16,6 @@ export interface InventorySet {
|
||||
readonly nodes: readonly ArtifactNode[];
|
||||
readonly occurrences: readonly ArtifactOccurrence[];
|
||||
readonly edges: readonly ArtifactInventoryResult["edges"]["items"][number][];
|
||||
readonly integrityContradictions: readonly IntegrityContradiction[];
|
||||
readonly limitations: readonly string[];
|
||||
readonly complete: boolean;
|
||||
}
|
||||
@@ -101,13 +99,6 @@ const assembleInventorySet = (
|
||||
for (const page of pages) {
|
||||
if (canonicalJson(page.inventory.manifest) !== manifestJson)
|
||||
throw new TypeError("Artifact inventory pages do not share one manifest");
|
||||
if (
|
||||
canonicalJson(page.inventory.integrity_contradictions) !==
|
||||
canonicalJson(first.inventory.integrity_contradictions)
|
||||
)
|
||||
throw new TypeError(
|
||||
"Artifact inventory pages do not share integrity contradictions",
|
||||
);
|
||||
for (const node of page.inventory.nodes.items)
|
||||
mergeExact(nodes, node.artifact_id, node, "node");
|
||||
for (const occurrence of page.inventory.occurrences.items) {
|
||||
@@ -157,7 +148,6 @@ const orderedInventory = (
|
||||
nodes,
|
||||
occurrences,
|
||||
edges,
|
||||
integrityContradictions: first.integrity_contradictions,
|
||||
limitations: [
|
||||
...new Set(pages.flatMap(({ inventory }) => inventory.limitations)),
|
||||
].sort((left, right) => left.localeCompare(right)),
|
||||
@@ -184,30 +174,10 @@ const validateCompleteInventory = (inventory: InventorySet): void => {
|
||||
validateOccurrence(occurrence, inventory, nodeIds, occurrenceIds);
|
||||
for (const edge of inventory.edges)
|
||||
validateEdge(edge, nodeIds, occurrenceIds);
|
||||
for (const contradiction of inventory.integrityContradictions) {
|
||||
if (
|
||||
!nodeIds.has(contradiction.parent_artifact_id) ||
|
||||
!occurrenceIds.has(contradiction.occurrence_id)
|
||||
)
|
||||
throw new TypeError(
|
||||
"Integrity contradiction references a missing graph member",
|
||||
);
|
||||
const expectedId = `ic_${digestCanonical({
|
||||
root_artifact_id: inventory.manifest.root_artifact_id,
|
||||
logical_path: contradiction.logical_path,
|
||||
declared_sha256: contradiction.declared_sha256,
|
||||
observed_sha256: contradiction.observed_sha256,
|
||||
})}`;
|
||||
if (contradiction.contradiction_id !== expectedId)
|
||||
throw new TypeError(
|
||||
"Integrity contradiction ID does not match its identity",
|
||||
);
|
||||
}
|
||||
const graphSha256 = digestCanonical({
|
||||
nodes: inventory.nodes,
|
||||
occurrences: inventory.occurrences,
|
||||
edges: inventory.edges,
|
||||
integrity_contradictions: inventory.integrityContradictions,
|
||||
});
|
||||
if (graphSha256 !== inventory.manifest.graph_sha256)
|
||||
throw new TypeError("Artifact graph commitment does not match its members");
|
||||
|
||||
@@ -1,397 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const parseExactOrigin = (value: string): string | undefined => {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
(url.protocol !== "http:" && url.protocol !== "https:") ||
|
||||
url.hostname.includes("*") ||
|
||||
url.username !== "" ||
|
||||
url.password !== "" ||
|
||||
url.pathname !== "/" ||
|
||||
url.search !== "" ||
|
||||
url.hash !== ""
|
||||
)
|
||||
return undefined;
|
||||
return url.origin;
|
||||
};
|
||||
|
||||
const MAX_BROWSER_URL_CHARS = 65_536;
|
||||
const MAX_SANITIZED_BROWSER_URL_CHARS = 131_072;
|
||||
const MAX_QUERY_PARAMETER_NAMES = 256;
|
||||
const MAX_QUERY_PARAMETER_NAME_CHARS = 256;
|
||||
|
||||
/** Exact normalized HTTP(S) authority used for browser observation scope. */
|
||||
export const browserOriginSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(2_048)
|
||||
.transform((value, context) => {
|
||||
const origin = parseExactOrigin(value);
|
||||
if (origin === undefined) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Expected one exact HTTP(S) origin without a path or wildcard",
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
return origin;
|
||||
});
|
||||
|
||||
const isLiteralLoopback = (hostname: string): boolean =>
|
||||
hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
|
||||
|
||||
/** Loopback-only HTTP endpoint accepted for a user-owned CDP browser. */
|
||||
export const browserEndpointSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(2_048)
|
||||
.transform((value, context) => {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
context.addIssue({ code: "custom", message: "Invalid CDP endpoint URL" });
|
||||
return z.NEVER;
|
||||
}
|
||||
if (
|
||||
url.protocol !== "http:" ||
|
||||
!isLiteralLoopback(url.hostname) ||
|
||||
url.port === "" ||
|
||||
url.username !== "" ||
|
||||
url.password !== "" ||
|
||||
url.pathname !== "/" ||
|
||||
url.search !== "" ||
|
||||
url.hash !== ""
|
||||
) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"CDP endpoint must be an explicit-port HTTP URL on 127.0.0.1 or ::1",
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
return url.origin;
|
||||
});
|
||||
|
||||
const allowedOriginsSchema = z
|
||||
.array(browserOriginSchema)
|
||||
.min(1)
|
||||
.max(32)
|
||||
.transform((origins) => [...new Set(origins)].sort());
|
||||
|
||||
const approvedBrowserInput = {
|
||||
cdp_endpoint: browserEndpointSchema,
|
||||
allowed_origins: allowedOriginsSchema,
|
||||
approved: z
|
||||
.literal(true)
|
||||
.describe("Explicit approval for browser observation"),
|
||||
};
|
||||
|
||||
/** Public input for bounded discovery of allowed page targets. */
|
||||
export const listBrowserTargetsInputSchema = z.object({
|
||||
...approvedBrowserInput,
|
||||
offset: z.number().int().min(0).default(0),
|
||||
limit: z.number().int().min(1).max(200).default(100),
|
||||
});
|
||||
|
||||
const browserInspectionLimitsSchema = z.object({
|
||||
max_frames: z.number().int().min(1).max(1_000).default(200),
|
||||
max_dom_nodes: z.number().int().min(1).max(10_000).default(2_000),
|
||||
max_ax_nodes: z.number().int().min(1).max(10_000).default(2_000),
|
||||
max_scripts: z.number().int().min(1).max(1_000).default(200),
|
||||
max_resources: z.number().int().min(1).max(10_000).default(2_000),
|
||||
max_workers: z.number().int().min(1).max(5_000).default(500),
|
||||
max_storage_keys: z.number().int().min(1).max(10_000).default(1_000),
|
||||
max_script_source_bytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(4 * 1_024 * 1_024)
|
||||
.default(1_024 * 1_024),
|
||||
max_total_script_source_bytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(16 * 1_024 * 1_024)
|
||||
.default(4 * 1_024 * 1_024),
|
||||
max_network_events: z.number().int().min(1).max(10_000).default(1_000),
|
||||
max_console_events: z.number().int().min(1).max(2_000).default(200),
|
||||
max_websocket_events: z.number().int().min(1).max(5_000).default(500),
|
||||
});
|
||||
|
||||
const DEFAULT_BROWSER_INSPECTION_LIMITS = {
|
||||
max_frames: 200,
|
||||
max_dom_nodes: 2_000,
|
||||
max_ax_nodes: 2_000,
|
||||
max_scripts: 200,
|
||||
max_resources: 2_000,
|
||||
max_workers: 500,
|
||||
max_storage_keys: 1_000,
|
||||
max_script_source_bytes: 1_024 * 1_024,
|
||||
max_total_script_source_bytes: 4 * 1_024 * 1_024,
|
||||
max_network_events: 1_000,
|
||||
max_console_events: 200,
|
||||
max_websocket_events: 500,
|
||||
} as const;
|
||||
|
||||
/** Public input for one passive, bounded inspection of an existing page. */
|
||||
export const inspectWebPageInputSchema = z
|
||||
.object({
|
||||
...approvedBrowserInput,
|
||||
target_id: z.string().trim().min(1).max(256),
|
||||
observation_ms: z.number().int().min(0).max(10_000).default(500),
|
||||
include_script_sources: z.boolean().default(false),
|
||||
include_storage_keys: z.boolean().default(false),
|
||||
limits: browserInspectionLimitsSchema.default(
|
||||
DEFAULT_BROWSER_INSPECTION_LIMITS,
|
||||
),
|
||||
})
|
||||
.superRefine((input, context) => {
|
||||
if (
|
||||
input.limits.max_script_source_bytes >
|
||||
input.limits.max_total_script_source_bytes
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["limits", "max_script_source_bytes"],
|
||||
message: "Per-script source limit cannot exceed the total source limit",
|
||||
});
|
||||
});
|
||||
|
||||
export type ListBrowserTargetsInput = z.infer<
|
||||
typeof listBrowserTargetsInputSchema
|
||||
>;
|
||||
export type InspectWebPageInput = z.infer<typeof inspectWebPageInputSchema>;
|
||||
|
||||
const sanitizedBrowserUrlSchema = z.object({
|
||||
url: z.string().max(MAX_SANITIZED_BROWSER_URL_CHARS),
|
||||
origin: z.string().max(2_048).nullable(),
|
||||
query_parameter_names: z
|
||||
.array(z.string().max(MAX_QUERY_PARAMETER_NAME_CHARS))
|
||||
.max(MAX_QUERY_PARAMETER_NAMES),
|
||||
redacted: z.boolean(),
|
||||
});
|
||||
export type SanitizedBrowserUrl = z.infer<typeof sanitizedBrowserUrlSchema>;
|
||||
|
||||
/** Remove credentials and query values before a browser URL becomes durable. */
|
||||
export const sanitizeBrowserUrl = (value: string): SanitizedBrowserUrl => {
|
||||
let parsed: URL;
|
||||
const wasTruncated = value.length > MAX_BROWSER_URL_CHARS;
|
||||
try {
|
||||
parsed = new URL(value.slice(0, MAX_BROWSER_URL_CHARS));
|
||||
} catch {
|
||||
return {
|
||||
url: "[unsupported-url]",
|
||||
origin: null,
|
||||
query_parameter_names: [],
|
||||
redacted: true,
|
||||
};
|
||||
}
|
||||
const hadCredentials = parsed.username !== "" || parsed.password !== "";
|
||||
const hadFragment = parsed.hash !== "";
|
||||
const names = [
|
||||
...new Set(
|
||||
[...parsed.searchParams.keys()].map((name) =>
|
||||
name.slice(0, MAX_QUERY_PARAMETER_NAME_CHARS),
|
||||
),
|
||||
),
|
||||
]
|
||||
.sort()
|
||||
.slice(0, MAX_QUERY_PARAMETER_NAMES);
|
||||
parsed.username = "";
|
||||
parsed.password = "";
|
||||
parsed.hash = "";
|
||||
parsed.search = "";
|
||||
for (const name of names) parsed.searchParams.append(name, "[REDACTED]");
|
||||
return {
|
||||
url: parsed.href,
|
||||
origin: parsed.origin === "null" ? null : parsed.origin,
|
||||
query_parameter_names: names,
|
||||
redacted: wasTruncated || hadCredentials || hadFragment || names.length > 0,
|
||||
};
|
||||
};
|
||||
|
||||
const browserVersionSchema = z.object({
|
||||
product: z.string(),
|
||||
protocol_version: z.string(),
|
||||
revision: z.string(),
|
||||
user_agent: z.string(),
|
||||
js_version: z.string(),
|
||||
});
|
||||
|
||||
const browserTargetSchema = z.object({
|
||||
target_id: z.string(),
|
||||
type: z.string(),
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
origin: z.string(),
|
||||
attached: z.boolean(),
|
||||
});
|
||||
|
||||
/** Bounded and policy-filtered browser target discovery result. */
|
||||
export const browserTargetListSchema = z.object({
|
||||
schema_version: z.literal(1),
|
||||
browser: browserVersionSchema,
|
||||
targets: z.object({
|
||||
items: z.array(browserTargetSchema),
|
||||
offset: z.number().int().min(0),
|
||||
limit: z.number().int().min(1),
|
||||
total: z.number().int().min(0),
|
||||
next_offset: z.number().int().min(0).nullable(),
|
||||
has_more: z.boolean(),
|
||||
}),
|
||||
excluded: z.object({
|
||||
disallowed_origin: z.number().int().min(0),
|
||||
unsupported_url: z.number().int().min(0),
|
||||
non_page: z.number().int().min(0),
|
||||
}),
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
export type BrowserTargetList = z.infer<typeof browserTargetListSchema>;
|
||||
|
||||
const browserFrameSchema = z.object({
|
||||
frame_id: z.string(),
|
||||
parent_frame_id: z.string().nullable(),
|
||||
url: z.string(),
|
||||
origin: z.string().nullable(),
|
||||
});
|
||||
const browserDomNodeSchema = z.object({
|
||||
index: z.number().int().min(0),
|
||||
parent_index: z.number().int().min(-1),
|
||||
node_type: z.number().int().min(0),
|
||||
node_name: z.string(),
|
||||
node_value_length: z.number().int().min(0),
|
||||
attribute_names: z.array(z.string()),
|
||||
});
|
||||
const browserAxNodeSchema = z.object({
|
||||
node_id: z.string(),
|
||||
parent_id: z.string().nullable(),
|
||||
role: z.string().nullable(),
|
||||
name: z.string().nullable(),
|
||||
description: z.string().nullable(),
|
||||
ignored: z.boolean(),
|
||||
});
|
||||
const browserScriptSourceSchema = z.discriminatedUnion("included", [
|
||||
z.object({ included: z.literal(false), reason: z.string() }),
|
||||
z.object({
|
||||
included: z.literal(true),
|
||||
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
bytes: z.number().int().min(0),
|
||||
content: z.string(),
|
||||
}),
|
||||
]);
|
||||
const browserScriptSchema = z.object({
|
||||
script_id: z.string(),
|
||||
url: z.string(),
|
||||
origin: z.string().nullable(),
|
||||
cdp_hash: z.string(),
|
||||
length: z.number().int().min(0),
|
||||
is_module: z.boolean(),
|
||||
language: z.string().nullable(),
|
||||
source_map_url: z.string().nullable(),
|
||||
source: browserScriptSourceSchema,
|
||||
});
|
||||
const browserResourceSchema = z.object({
|
||||
url: z.string(),
|
||||
origin: z.string().nullable(),
|
||||
type: z.string(),
|
||||
mime_type: z.string(),
|
||||
content_size: z.number().min(0).nullable(),
|
||||
});
|
||||
const browserNetworkRequestSchema = z.object({
|
||||
request_id: z.string(),
|
||||
url: z.string(),
|
||||
origin: z.string().nullable(),
|
||||
method: z.string(),
|
||||
resource_type: z.string().nullable(),
|
||||
status: z.number().nullable(),
|
||||
mime_type: z.string().nullable(),
|
||||
encoded_data_length: z.number().min(0).nullable(),
|
||||
initiator: z.object({
|
||||
type: z.string(),
|
||||
url: z.string().nullable(),
|
||||
line: z.number().int().min(0).nullable(),
|
||||
column: z.number().int().min(0).nullable(),
|
||||
}),
|
||||
});
|
||||
const browserConsoleEventSchema = z.object({
|
||||
type: z.string(),
|
||||
timestamp: z.number(),
|
||||
argument_types: z.array(z.string()),
|
||||
url: z.string().nullable(),
|
||||
line: z.number().int().min(0).nullable(),
|
||||
column: z.number().int().min(0).nullable(),
|
||||
});
|
||||
const browserWebSocketEventSchema = z.object({
|
||||
request_id: z.string(),
|
||||
direction: z.enum(["sent", "received"]),
|
||||
opcode: z.number().int().min(0),
|
||||
payload_bytes: z.number().int().min(0),
|
||||
});
|
||||
const browserWorkerSchema = z.object({
|
||||
target_id: z.string(),
|
||||
type: z.string(),
|
||||
url: z.string(),
|
||||
origin: z.string().nullable(),
|
||||
attached: z.boolean(),
|
||||
});
|
||||
|
||||
/** Provider-neutral normalized result for one passive web-page observation. */
|
||||
export const webPageInspectionSchema = z.object({
|
||||
schema_version: z.literal(1),
|
||||
browser: browserVersionSchema,
|
||||
target: browserTargetSchema,
|
||||
capture_window: z.object({
|
||||
started_at: z.iso.datetime(),
|
||||
ended_at: z.iso.datetime(),
|
||||
observation_ms: z.number().int().min(0),
|
||||
}),
|
||||
completeness: z.object({
|
||||
status: z.enum(["complete", "truncated"]),
|
||||
truncated_sections: z.array(z.string()),
|
||||
dropped_events: z.number().int().min(0),
|
||||
}),
|
||||
frames: z.array(browserFrameSchema),
|
||||
dom: z.object({
|
||||
total_nodes: z.number().int().min(0),
|
||||
nodes: z.array(browserDomNodeSchema),
|
||||
}),
|
||||
accessibility: z.object({
|
||||
total_nodes: z.number().int().min(0),
|
||||
nodes: z.array(browserAxNodeSchema),
|
||||
}),
|
||||
scripts: z.object({
|
||||
total: z.number().int().min(0),
|
||||
items: z.array(browserScriptSchema),
|
||||
}),
|
||||
resources: z.array(browserResourceSchema),
|
||||
network: z.object({
|
||||
requests: z.array(browserNetworkRequestSchema),
|
||||
websocket_events: z.array(browserWebSocketEventSchema),
|
||||
coverage_started_at: z.iso.datetime(),
|
||||
prior_activity_available: z.literal(false),
|
||||
}),
|
||||
console: z.object({
|
||||
events: z.array(browserConsoleEventSchema),
|
||||
coverage_started_at: z.iso.datetime(),
|
||||
prior_activity_available: z.literal(false),
|
||||
}),
|
||||
workers: z.array(browserWorkerSchema),
|
||||
storage: z.object({
|
||||
origin: z.string(),
|
||||
usage_bytes: z.number().min(0).nullable(),
|
||||
quota_bytes: z.number().min(0).nullable(),
|
||||
local_storage_keys: z.array(z.string()),
|
||||
session_storage_keys: z.array(z.string()),
|
||||
indexed_db_names: z.array(z.string()),
|
||||
cache_names: z.array(z.string()),
|
||||
values_redacted: z.literal(true),
|
||||
}),
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
export type WebPageInspection = z.infer<typeof webPageInspectionSchema>;
|
||||
@@ -331,11 +331,9 @@ const artifactFindings = (evidence: Evidence): Finding[] => {
|
||||
scope: "static_candidate",
|
||||
status: change.classification,
|
||||
classification:
|
||||
change.classification === "contradiction"
|
||||
? "contradiction"
|
||||
: change.classification === "unknown"
|
||||
? "unresolved_branch"
|
||||
: "derived_relationship",
|
||||
change.classification === "unknown"
|
||||
? "unresolved_branch"
|
||||
: "derived_relationship",
|
||||
limitations: result.limitations,
|
||||
links: change.evidence_links,
|
||||
}),
|
||||
|
||||
+18
-309
@@ -1,14 +1,10 @@
|
||||
import type { JsonValue } from "./jsonValue.js";
|
||||
import type {
|
||||
MissingPermissionScope,
|
||||
PermissionRequest,
|
||||
PermissionScope,
|
||||
} from "./permissionPolicy.js";
|
||||
import {
|
||||
hopperStartupFailure,
|
||||
type HopperStartupFailureCode,
|
||||
} from "./hopperStartupFailure.js";
|
||||
export { hopperStartupFailure, type HopperStartupFailureCode };
|
||||
export { hopperStartupFailure } from "./hopperStartupFailure.js";
|
||||
export type { HopperStartupFailureCode } from "./hopperStartupFailure.js";
|
||||
|
||||
/** Stable tags exposed by safe analysis-error projections. */
|
||||
const ANALYSIS_ERROR_TAGS = [
|
||||
@@ -20,7 +16,6 @@ const ANALYSIS_ERROR_TAGS = [
|
||||
"AnalysisTimeoutError",
|
||||
"ProviderSelectionError",
|
||||
"ProviderAdapterError",
|
||||
"BrowserObservationError",
|
||||
"ArtifactOperationError",
|
||||
"ProcessCaptureError",
|
||||
"EvidenceIntegrityError",
|
||||
@@ -37,7 +32,6 @@ const ANALYSIS_ERROR_TAGS = [
|
||||
"ConfigurationError",
|
||||
"NoBinaryOpenError",
|
||||
"BinaryTargetError",
|
||||
"PermissionRequiredError",
|
||||
] as const;
|
||||
|
||||
/** Stable tag for an expected analysis failure. */
|
||||
@@ -49,8 +43,6 @@ export abstract class AnalysisError extends Error {
|
||||
readonly userMessage: string | undefined = undefined;
|
||||
readonly userCategory: "permission_required" | "cancelled" | undefined =
|
||||
undefined;
|
||||
readonly cleanupIncomplete: boolean = false;
|
||||
readonly cleanupResources: readonly string[] = [];
|
||||
}
|
||||
|
||||
/** Base class for failures produced specifically by the Hopper provider. */
|
||||
@@ -144,27 +136,6 @@ export class ProviderAdapterError extends AnalysisError {
|
||||
}
|
||||
}
|
||||
|
||||
/** A bounded passive browser observation failed at its CDP boundary. */
|
||||
export class BrowserObservationError extends AnalysisError {
|
||||
readonly _tag = "BrowserObservationError";
|
||||
|
||||
constructor(
|
||||
readonly operation: "list_browser_targets" | "inspect_web_page",
|
||||
readonly reason:
|
||||
| "endpoint_unreachable"
|
||||
| "invalid_endpoint_response"
|
||||
| "target_not_found"
|
||||
| "target_not_allowed"
|
||||
| "target_changed"
|
||||
| "protocol_error"
|
||||
| "disconnected"
|
||||
| "payload_limit",
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(`Browser observation ${operation} failed: ${reason}`, options);
|
||||
}
|
||||
}
|
||||
|
||||
/** Artifact inventory or extraction failed a typed safety boundary. */
|
||||
export class ArtifactOperationError extends AnalysisError {
|
||||
readonly _tag = "ArtifactOperationError";
|
||||
@@ -366,25 +337,6 @@ export class BinaryTargetError extends AnalysisError {
|
||||
|
||||
export interface AnalysisErrorProjection
|
||||
extends Readonly<Record<string, JsonValue>> {
|
||||
readonly code:
|
||||
| "invalid_request"
|
||||
| "unreadable_output"
|
||||
| "capability_unavailable"
|
||||
| "provider_unavailable"
|
||||
| "provider_timeout"
|
||||
| "cancelled"
|
||||
| "artifact_integrity_mismatch"
|
||||
| "artifact_operation_failed"
|
||||
| "evidence_integrity_mismatch"
|
||||
| "truncated"
|
||||
| "permission_required"
|
||||
| "process_capture_failed"
|
||||
| "cleanup_incomplete"
|
||||
| "revision_conflict"
|
||||
| "outside_approved_root"
|
||||
| "configuration_invalid"
|
||||
| "target_unavailable"
|
||||
| "execution_failure";
|
||||
readonly category:
|
||||
| "invalid_input"
|
||||
| "permission_required"
|
||||
@@ -396,280 +348,41 @@ export interface AnalysisErrorProjection
|
||||
| "unavailable"
|
||||
| "execution_failure";
|
||||
readonly message: string;
|
||||
readonly retryable: boolean;
|
||||
readonly remediation: Readonly<{
|
||||
action: string;
|
||||
restart_required: boolean;
|
||||
elicitation_supported?: boolean;
|
||||
}>;
|
||||
readonly code?: HopperStartupFailureCode;
|
||||
readonly details?: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
|
||||
/** Exact denied authority used by both CLI and MCP remediation. */
|
||||
export class PermissionRequiredError extends AnalysisError {
|
||||
readonly _tag = "PermissionRequiredError" as const;
|
||||
|
||||
constructor(
|
||||
readonly requested: PermissionRequest,
|
||||
readonly missing: MissingPermissionScope,
|
||||
readonly ceiling: PermissionScope | null,
|
||||
readonly elicitationSupported: boolean,
|
||||
readonly restartRequired: boolean,
|
||||
) {
|
||||
super(`Permission required for ${requested.capability}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Project expected failures into exhaustive, secret-safe caller fields. */
|
||||
export const projectAnalysisError = (
|
||||
error: AnalysisError,
|
||||
): AnalysisErrorProjection => {
|
||||
assertKnownTag(error._tag);
|
||||
const code = errorCode(error);
|
||||
const details = errorDetails(error);
|
||||
const code =
|
||||
error instanceof HopperProcessError ? error.failureCode : undefined;
|
||||
const details =
|
||||
error instanceof ArtifactOperationError ? error.artifactDetails : undefined;
|
||||
return {
|
||||
code,
|
||||
category: errorCategory(error),
|
||||
message: userMessage(error),
|
||||
retryable: RETRYABLE_CODES.has(code),
|
||||
remediation: {
|
||||
action: remediationAction(error),
|
||||
restart_required:
|
||||
error instanceof PermissionRequiredError && error.restartRequired,
|
||||
...(error instanceof PermissionRequiredError
|
||||
? { elicitation_supported: error.elicitationSupported }
|
||||
: {}),
|
||||
},
|
||||
...(details === undefined ? {} : { details }),
|
||||
...(code === undefined ? {} : { code }),
|
||||
...(details === undefined
|
||||
? {}
|
||||
: {
|
||||
details: {
|
||||
logical_path: details.logicalPath,
|
||||
declared_sha256: details.declaredSha256,
|
||||
calculated_sha256: details.calculatedSha256,
|
||||
unpacked: details.unpacked,
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const errorCode = (error: AnalysisError): AnalysisErrorProjection["code"] => {
|
||||
if (error instanceof PermissionRequiredError) return "permission_required";
|
||||
if (error instanceof BrowserObservationError) {
|
||||
if (error.reason === "payload_limit") return "truncated";
|
||||
if (
|
||||
error.reason === "target_not_found" ||
|
||||
error.reason === "target_not_allowed" ||
|
||||
error.reason === "target_changed"
|
||||
)
|
||||
return "target_unavailable";
|
||||
if (
|
||||
error.reason === "endpoint_unreachable" ||
|
||||
error.reason === "disconnected"
|
||||
)
|
||||
return "provider_unavailable";
|
||||
return "unreadable_output";
|
||||
}
|
||||
if (error instanceof ArtifactOperationError)
|
||||
return error.reason === "integrity" && error.artifactDetails !== undefined
|
||||
? "artifact_integrity_mismatch"
|
||||
: error.reason === "limit"
|
||||
? "truncated"
|
||||
: error.reason === "cancelled"
|
||||
? "cancelled"
|
||||
: "artifact_operation_failed";
|
||||
if (error instanceof EvidenceFileError)
|
||||
return error.reason === "outside-root"
|
||||
? "outside_approved_root"
|
||||
: error.reason === "too-large"
|
||||
? "truncated"
|
||||
: error.reason === "disabled"
|
||||
? "capability_unavailable"
|
||||
: "execution_failure";
|
||||
if (error instanceof InvestigationWorkspaceError) {
|
||||
if (
|
||||
error.reason === "revision-conflict" ||
|
||||
error.reason === "name-conflict"
|
||||
)
|
||||
return "revision_conflict";
|
||||
if (error.reason === "too-large") return "truncated";
|
||||
if (error.reason === "disabled") return "capability_unavailable";
|
||||
if (error.reason === "outside-root") return "outside_approved_root";
|
||||
if (error.reason === "integrity" || error.reason === "invalid-json")
|
||||
return "evidence_integrity_mismatch";
|
||||
return "execution_failure";
|
||||
}
|
||||
if (error instanceof UnknownRegistryError)
|
||||
return error.reason === "revision-conflict" ||
|
||||
error.reason === "already-exists"
|
||||
? "revision_conflict"
|
||||
: error.reason === "limit"
|
||||
? "truncated"
|
||||
: error.reason === "integrity"
|
||||
? "evidence_integrity_mismatch"
|
||||
: "execution_failure";
|
||||
if (error._tag === "ProcessCaptureError")
|
||||
return error.cleanupIncomplete
|
||||
? "cleanup_incomplete"
|
||||
: error.userCategory === "cancelled"
|
||||
? "cancelled"
|
||||
: error.userCategory === "permission_required"
|
||||
? "permission_required"
|
||||
: "process_capture_failed";
|
||||
switch (error._tag) {
|
||||
case "AnalysisProtocolError":
|
||||
case "AnalysisOutputError":
|
||||
case "HopperProtocolError":
|
||||
return "unreadable_output";
|
||||
case "AnalysisInputError":
|
||||
return "invalid_request";
|
||||
case "AnalysisCapabilityUnavailableError":
|
||||
case "ProviderSelectionError":
|
||||
return "capability_unavailable";
|
||||
case "AnalysisCancelledError":
|
||||
case "HopperCancelledError":
|
||||
return "cancelled";
|
||||
case "AnalysisTimeoutError":
|
||||
case "HopperTimeoutError":
|
||||
return "provider_timeout";
|
||||
case "HopperProcessError":
|
||||
case "HopperStartError":
|
||||
return "provider_unavailable";
|
||||
case "ConfigurationError":
|
||||
return "configuration_invalid";
|
||||
case "NoBinaryOpenError":
|
||||
case "BinaryTargetError":
|
||||
return "target_unavailable";
|
||||
case "EvidenceIntegrityError":
|
||||
return "evidence_integrity_mismatch";
|
||||
case "EvidenceLimitError":
|
||||
return "truncated";
|
||||
case "ProviderAdapterError":
|
||||
case "HopperRemoteError":
|
||||
return "execution_failure";
|
||||
case "BrowserObservationError":
|
||||
throw new TypeError("Unhandled specialized browser error");
|
||||
case "ArtifactOperationError":
|
||||
case "EvidenceFileError":
|
||||
case "InvestigationWorkspaceError":
|
||||
case "UnknownRegistryError":
|
||||
case "PermissionRequiredError":
|
||||
throw new TypeError("Unhandled specialized analysis error");
|
||||
}
|
||||
};
|
||||
|
||||
const errorDetails = (
|
||||
error: AnalysisError,
|
||||
): Readonly<Record<string, JsonValue>> | undefined => {
|
||||
if (error instanceof PermissionRequiredError)
|
||||
return {
|
||||
capability: error.requested.capability,
|
||||
requested: scopeDetails(error.requested),
|
||||
missing: missingScopeDetails(error.missing),
|
||||
ceiling: error.ceiling === null ? null : scopeDetails(error.ceiling),
|
||||
};
|
||||
if (error instanceof ArtifactOperationError && error.artifactDetails)
|
||||
return {
|
||||
logical_path: error.artifactDetails.logicalPath,
|
||||
declared_sha256: error.artifactDetails.declaredSha256,
|
||||
calculated_sha256: error.artifactDetails.calculatedSha256,
|
||||
unpacked: error.artifactDetails.unpacked,
|
||||
};
|
||||
if (error instanceof ArtifactOperationError)
|
||||
return {
|
||||
operation: error.operation,
|
||||
reason: error.reason,
|
||||
...(error.reason === "limit" ? { truncated: true } : {}),
|
||||
};
|
||||
if (error instanceof AnalysisCancelledError)
|
||||
return { operation: error.operation, cleanup: "complete" };
|
||||
if (error instanceof HopperCancelledError)
|
||||
return { operation: "hopper", cleanup: "complete" };
|
||||
if (error instanceof AnalysisTimeoutError)
|
||||
return { operation: error.operation, timeout_ms: error.timeoutMs };
|
||||
if (error instanceof HopperTimeoutError)
|
||||
return { operation: "hopper", timeout_ms: error.timeoutMs };
|
||||
if (error instanceof EvidenceLimitError)
|
||||
return { limit: error.limit, maximum: error.maximum, truncated: true };
|
||||
if (error instanceof InvestigationWorkspaceError)
|
||||
return { operation: error.operation, reason: error.reason };
|
||||
if (error instanceof UnknownRegistryError) return { reason: error.reason };
|
||||
if (error instanceof EvidenceFileError)
|
||||
return { operation: error.operation, reason: error.reason };
|
||||
if (error instanceof AnalysisCapabilityUnavailableError)
|
||||
return { provider_id: error.providerId, operation: error.operation };
|
||||
if (error instanceof ProviderAdapterError)
|
||||
return { provider_id: error.providerId, operation: error.operation };
|
||||
if (error instanceof BrowserObservationError)
|
||||
return { operation: error.operation, reason: error.reason };
|
||||
if (error instanceof HopperRemoteError)
|
||||
return { provider_code: error.code, diagnostic_type: error.diagnosticType };
|
||||
if (error instanceof HopperProcessError && error.failureCode !== undefined)
|
||||
return { failure_code: error.failureCode, exit_code: error.exitCode };
|
||||
if (error._tag === "ProcessCaptureError" && error.cleanupIncomplete)
|
||||
return {
|
||||
cleanup: "incomplete",
|
||||
resources: [...error.cleanupResources],
|
||||
};
|
||||
if (
|
||||
error._tag === "ProcessCaptureError" &&
|
||||
error.userCategory === "cancelled"
|
||||
)
|
||||
return { operation: "process_capture", cleanup: "complete" };
|
||||
if (error instanceof BinaryTargetError) return { path: error.path };
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const scopeDetails = (
|
||||
scope: PermissionScope,
|
||||
): Readonly<Record<string, JsonValue>> => ({
|
||||
capability: scope.capability,
|
||||
roots: [...scope.roots],
|
||||
executables: [...scope.executables],
|
||||
environment_names: [...scope.environment_names],
|
||||
...(scope.origins === undefined ? {} : { origins: [...scope.origins] }),
|
||||
network: scope.network,
|
||||
mount: scope.mount,
|
||||
});
|
||||
|
||||
const missingScopeDetails = (
|
||||
scope: MissingPermissionScope,
|
||||
): Readonly<Record<string, JsonValue>> => ({
|
||||
...(scope.roots === undefined ? {} : { roots: [...scope.roots] }),
|
||||
...(scope.executables === undefined
|
||||
? {}
|
||||
: { executables: [...scope.executables] }),
|
||||
...(scope.environment_names === undefined
|
||||
? {}
|
||||
: { environment_names: [...scope.environment_names] }),
|
||||
...(scope.origins === undefined ? {} : { origins: [...scope.origins] }),
|
||||
...(scope.network === undefined ? {} : { network: scope.network }),
|
||||
...(scope.mount === undefined ? {} : { mount: scope.mount }),
|
||||
});
|
||||
|
||||
const RETRYABLE_CODES: ReadonlySet<AnalysisErrorProjection["code"]> = new Set([
|
||||
"provider_timeout",
|
||||
"cancelled",
|
||||
"revision_conflict",
|
||||
"provider_unavailable",
|
||||
]);
|
||||
|
||||
const remediationAction = (error: AnalysisError): string => {
|
||||
if (error instanceof PermissionRequiredError)
|
||||
return error.elicitationSupported
|
||||
? "Approve the exact missing scope, then retry the operation."
|
||||
: "Add the exact missing scope beneath the administrator ceiling, then retry.";
|
||||
return userMessage(error);
|
||||
};
|
||||
|
||||
const errorCategory = (
|
||||
error: AnalysisError,
|
||||
): AnalysisErrorProjection["category"] => {
|
||||
if (error instanceof PermissionRequiredError) return "permission_required";
|
||||
if (error._tag === "ProcessCaptureError")
|
||||
return error.userCategory ?? "execution_failure";
|
||||
if (error instanceof BrowserObservationError) {
|
||||
if (error.reason === "payload_limit") return "truncated";
|
||||
if (
|
||||
error.reason === "target_not_found" ||
|
||||
error.reason === "target_not_allowed" ||
|
||||
error.reason === "target_changed" ||
|
||||
error.reason === "endpoint_unreachable" ||
|
||||
error.reason === "disconnected"
|
||||
)
|
||||
return "unavailable";
|
||||
}
|
||||
if (
|
||||
error instanceof HopperRemoteError &&
|
||||
error.diagnosticType === "authorization"
|
||||
@@ -726,8 +439,6 @@ const STATIC_ERROR_CATEGORIES: Readonly<
|
||||
};
|
||||
|
||||
const userMessage = (error: AnalysisError): string => {
|
||||
if (error instanceof PermissionRequiredError)
|
||||
return "This operation needs additional local permission. Review the requested scope and remediation.";
|
||||
if (error instanceof AnalysisInputError)
|
||||
return "Analysis input is invalid. Check the arguments and try again.";
|
||||
if (error.userMessage !== undefined) return error.userMessage;
|
||||
@@ -853,7 +564,6 @@ const KNOWN_ERROR_TAGS = {
|
||||
AnalysisTimeoutError: true,
|
||||
ProviderSelectionError: true,
|
||||
ProviderAdapterError: true,
|
||||
BrowserObservationError: true,
|
||||
ArtifactOperationError: true,
|
||||
ProcessCaptureError: true,
|
||||
EvidenceIntegrityError: true,
|
||||
@@ -870,7 +580,6 @@ const KNOWN_ERROR_TAGS = {
|
||||
ConfigurationError: true,
|
||||
NoBinaryOpenError: true,
|
||||
BinaryTargetError: true,
|
||||
PermissionRequiredError: true,
|
||||
} as const satisfies Readonly<Record<AnalysisErrorTag, true>>;
|
||||
|
||||
const assertKnownTag = (tag: AnalysisErrorTag): void => {
|
||||
|
||||
@@ -53,32 +53,17 @@ export type InvestigationRunOptions = z.infer<
|
||||
>;
|
||||
|
||||
/** Explicit write approval and two local versions for an automatic run. */
|
||||
export const crossVersionInvestigationInputSchema = z
|
||||
.object({
|
||||
approved: z.literal(true),
|
||||
workspace_path: z.string().min(1).max(4_096),
|
||||
workspace_name: z.string().trim().min(1).max(200).default("default"),
|
||||
expected_workspace_revision: z.number().int().min(1).optional(),
|
||||
left_path: z.string().min(1).max(4_096),
|
||||
right_path: z.string().min(1).max(4_096),
|
||||
integrity_policy: z.enum(["fail", "record-and-continue"]).default("fail"),
|
||||
integrity_continue_approved: z.boolean().default(false),
|
||||
max_integrity_mismatches: z.number().int().min(1).max(100).default(10),
|
||||
options: investigationRunOptionsSchema.default(
|
||||
DEFAULT_INVESTIGATION_RUN_OPTIONS,
|
||||
),
|
||||
})
|
||||
.superRefine((input, context) => {
|
||||
if (
|
||||
input.integrity_policy === "record-and-continue" &&
|
||||
input.integrity_continue_approved !== true
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["integrity_continue_approved"],
|
||||
message: "record-and-continue requires explicit approval",
|
||||
});
|
||||
});
|
||||
export const crossVersionInvestigationInputSchema = z.object({
|
||||
approved: z.literal(true),
|
||||
workspace_path: z.string().min(1).max(4_096),
|
||||
workspace_name: z.string().trim().min(1).max(200).default("default"),
|
||||
expected_workspace_revision: z.number().int().min(1).optional(),
|
||||
left_path: z.string().min(1).max(4_096),
|
||||
right_path: z.string().min(1).max(4_096),
|
||||
options: investigationRunOptionsSchema.default(
|
||||
DEFAULT_INVESTIGATION_RUN_OPTIONS,
|
||||
),
|
||||
});
|
||||
|
||||
export type CrossVersionInvestigationInput = z.infer<
|
||||
typeof crossVersionInvestigationInputSchema
|
||||
@@ -100,16 +85,13 @@ const stageSchema = z.enum([
|
||||
"find_changed_behavior",
|
||||
]);
|
||||
|
||||
const investigationRunBaseSchema = z.object({
|
||||
export const investigationRunSchema = z.object({
|
||||
schema_version: z.literal(1),
|
||||
run_id: z.string().regex(/^run_[a-f0-9]{64}$/u),
|
||||
request_sha256: digestSchema,
|
||||
left: targetSchema,
|
||||
right: targetSchema,
|
||||
options: investigationRunOptionsSchema,
|
||||
integrity_policy: z.enum(["fail", "record-and-continue"]),
|
||||
integrity_continue_approved: z.boolean(),
|
||||
max_integrity_mismatches: z.number().int().min(1).max(100),
|
||||
status: z.enum(["running", "complete"]),
|
||||
completed_stages: z.array(stageSchema).max(4),
|
||||
left_inventory_evidence_ids: z.array(evidenceIdSchema).min(1).max(100),
|
||||
@@ -119,31 +101,6 @@ const investigationRunBaseSchema = z.object({
|
||||
limitations: z.array(z.string().max(4_096)).max(100),
|
||||
});
|
||||
|
||||
export const investigationRunSchema = z
|
||||
.union([
|
||||
investigationRunBaseSchema.extend({
|
||||
legacy_request_identity: z.literal(true),
|
||||
}),
|
||||
investigationRunBaseSchema,
|
||||
])
|
||||
.superRefine((run, context) => {
|
||||
if (!("legacy_request_identity" in run)) return;
|
||||
const legacyIdentity = createLegacyInvestigationRunIdentity(run);
|
||||
if (
|
||||
run.integrity_policy !== "fail" ||
|
||||
run.integrity_continue_approved ||
|
||||
run.max_integrity_mismatches !== 10 ||
|
||||
run.run_id !== legacyIdentity.runId ||
|
||||
run.request_sha256 !== legacyIdentity.requestSha256
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["legacy_request_identity"],
|
||||
message:
|
||||
"Legacy request identity is valid only for migrated strict runs",
|
||||
});
|
||||
});
|
||||
|
||||
export type InvestigationRun = z.infer<typeof investigationRunSchema>;
|
||||
|
||||
export const investigationRunSummarySchema = z.object({
|
||||
@@ -171,20 +128,6 @@ const investigationWorkspaceSchema = z.object({
|
||||
runs: z.array(investigationRunSchema).max(1_000),
|
||||
});
|
||||
|
||||
const legacyInvestigationRunSchema = investigationRunBaseSchema
|
||||
.omit({
|
||||
integrity_policy: true,
|
||||
integrity_continue_approved: true,
|
||||
max_integrity_mismatches: true,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const legacyInvestigationWorkspaceSchema = investigationWorkspaceSchema
|
||||
.extend({
|
||||
runs: z.array(legacyInvestigationRunSchema).max(1_000),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type InvestigationWorkspace = z.infer<
|
||||
typeof investigationWorkspaceSchema
|
||||
>;
|
||||
@@ -194,20 +137,12 @@ export const createInvestigationRunIdentity = (input: {
|
||||
readonly left: InvestigationRunTarget;
|
||||
readonly right: InvestigationRunTarget;
|
||||
readonly options: InvestigationRunOptions;
|
||||
readonly integrity_policy: CrossVersionInvestigationInput["integrity_policy"];
|
||||
readonly integrity_continue_approved: boolean;
|
||||
readonly max_integrity_mismatches: number;
|
||||
}): { readonly runId: string; readonly requestSha256: string } => {
|
||||
const requestSha256 = digestCanonical({
|
||||
schema: "rea.cross-version-investigation-request/v1",
|
||||
left: input.left,
|
||||
right: input.right,
|
||||
options: input.options,
|
||||
integrity: {
|
||||
policy: input.integrity_policy,
|
||||
approved: input.integrity_continue_approved,
|
||||
max_mismatches: input.max_integrity_mismatches,
|
||||
},
|
||||
});
|
||||
return { runId: `run_${requestSha256}`, requestSha256 };
|
||||
};
|
||||
@@ -246,7 +181,6 @@ export const reviseInvestigationWorkspace = (
|
||||
export const parseInvestigationWorkspace = (
|
||||
input: unknown,
|
||||
): InvestigationWorkspace => {
|
||||
if (isLegacyWorkspace(input)) return migrateLegacyWorkspace(input);
|
||||
const parsed = investigationWorkspaceSchema.parse(input);
|
||||
const bundle = parseEvidenceBundle(parsed.bundle);
|
||||
const canonical = buildWorkspace({
|
||||
@@ -308,16 +242,9 @@ const validateRunReferences = (workspace: InvestigationWorkspace): void => {
|
||||
);
|
||||
for (const run of workspace.runs) {
|
||||
const expectedIdentity = createInvestigationRunIdentity(run);
|
||||
const legacyIdentity = createLegacyInvestigationRunIdentity(run);
|
||||
if (
|
||||
(run.run_id !== expectedIdentity.runId ||
|
||||
run.request_sha256 !== expectedIdentity.requestSha256) &&
|
||||
(!("legacy_request_identity" in run) ||
|
||||
run.integrity_policy !== "fail" ||
|
||||
run.integrity_continue_approved ||
|
||||
run.max_integrity_mismatches !== 10 ||
|
||||
run.run_id !== legacyIdentity.runId ||
|
||||
run.request_sha256 !== legacyIdentity.requestSha256)
|
||||
run.run_id !== expectedIdentity.runId ||
|
||||
run.request_sha256 !== expectedIdentity.requestSha256
|
||||
)
|
||||
throw new TypeError("Investigation run identity is invalid");
|
||||
validateRunStages(run);
|
||||
@@ -334,85 +261,6 @@ const validateRunReferences = (workspace: InvestigationWorkspace): void => {
|
||||
}
|
||||
};
|
||||
|
||||
/** Derive the pre-integrity-policy run identity for strict workspace migration. */
|
||||
export const createLegacyInvestigationRunIdentity = (input: {
|
||||
readonly left: InvestigationRunTarget;
|
||||
readonly right: InvestigationRunTarget;
|
||||
readonly options: InvestigationRunOptions;
|
||||
}): { readonly runId: string; readonly requestSha256: string } => {
|
||||
const requestSha256 = digestCanonical({
|
||||
schema: "rea.cross-version-investigation-request/v1",
|
||||
left: input.left,
|
||||
right: input.right,
|
||||
options: input.options,
|
||||
});
|
||||
return { runId: `run_${requestSha256}`, requestSha256 };
|
||||
};
|
||||
|
||||
const isLegacyWorkspace = (input: unknown): boolean => {
|
||||
if (typeof input !== "object" || input === null || !("runs" in input))
|
||||
return false;
|
||||
const runs = input.runs;
|
||||
return (
|
||||
Array.isArray(runs) &&
|
||||
runs.some(
|
||||
(run) =>
|
||||
typeof run === "object" && run !== null && !("integrity_policy" in run),
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const migrateLegacyWorkspace = (input: unknown): InvestigationWorkspace => {
|
||||
const parsed = legacyInvestigationWorkspaceSchema.parse(input);
|
||||
const bundle = parseEvidenceBundle(parsed.bundle);
|
||||
const runs = [...parsed.runs].sort((left, right) =>
|
||||
left.run_id.localeCompare(right.run_id),
|
||||
);
|
||||
const semantic = {
|
||||
workspace_version: 1,
|
||||
workspace_id: parsed.workspace_id,
|
||||
name: parsed.name,
|
||||
revision: parsed.revision,
|
||||
previous_revision_digest: parsed.previous_revision_digest,
|
||||
bundle,
|
||||
runs,
|
||||
} satisfies JsonValue;
|
||||
if (parsed.workspace_id !== workspaceIdFor(parsed.name))
|
||||
throw new TypeError("Investigation workspace ID does not match its name");
|
||||
if (parsed.revision_digest !== `wrev_${digestCanonical(semantic)}`)
|
||||
throw new TypeError("Investigation workspace revision digest is invalid");
|
||||
const canonical = legacyInvestigationWorkspaceSchema.parse({
|
||||
...semantic,
|
||||
revision_digest: parsed.revision_digest,
|
||||
});
|
||||
if (JSON.stringify(parsed) !== JSON.stringify(canonical))
|
||||
throw new TypeError("Investigation workspace is not canonical");
|
||||
for (const run of runs) {
|
||||
const identity = createLegacyInvestigationRunIdentity(run);
|
||||
if (
|
||||
run.run_id !== identity.runId ||
|
||||
run.request_sha256 !== identity.requestSha256
|
||||
)
|
||||
throw new TypeError("Investigation run identity is invalid");
|
||||
}
|
||||
const migrated = buildWorkspace({
|
||||
workspaceId: parsed.workspace_id,
|
||||
name: parsed.name,
|
||||
revision: parsed.revision,
|
||||
previousRevisionDigest: parsed.previous_revision_digest,
|
||||
bundle,
|
||||
runs: runs.map((run) => ({
|
||||
...run,
|
||||
legacy_request_identity: true as const,
|
||||
integrity_policy: "fail" as const,
|
||||
integrity_continue_approved: false,
|
||||
max_integrity_mismatches: 10,
|
||||
})),
|
||||
});
|
||||
validateRunReferences(migrated);
|
||||
return migrated;
|
||||
};
|
||||
|
||||
const validateRunStages = (run: InvestigationRun): void => {
|
||||
const expected = [
|
||||
"inventory_left",
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
import { isAbsolute, relative, resolve, sep } from "node:path";
|
||||
|
||||
import { err, ok, type Result } from "./result.js";
|
||||
|
||||
/** Local capabilities governed by the shared REA permission policy. */
|
||||
export type PermissionCapability =
|
||||
| "process_capture"
|
||||
| "browser_observe"
|
||||
| "evidence_read"
|
||||
| "evidence_write"
|
||||
| "investigation_input"
|
||||
| "investigation_workspace_read"
|
||||
| "investigation_workspace_write"
|
||||
| "snapshot_read"
|
||||
| "snapshot_write"
|
||||
| "artifact_extract"
|
||||
| "native_mount"
|
||||
| "reference_read";
|
||||
|
||||
/** Ordered network authority; each mode includes the modes before it. */
|
||||
type PermissionNetwork = "none" | "loopback" | "external";
|
||||
|
||||
/** Exact normalized scope requested by one local operation. */
|
||||
export interface PermissionScope {
|
||||
readonly capability: PermissionCapability;
|
||||
readonly roots: readonly string[];
|
||||
readonly executables: readonly string[];
|
||||
readonly environment_names: readonly string[];
|
||||
readonly origins?: readonly string[] | undefined;
|
||||
readonly network: PermissionNetwork;
|
||||
readonly mount: boolean;
|
||||
}
|
||||
|
||||
/** A request identity associates one-shot grants with exactly one operation. */
|
||||
export interface PermissionRequest extends PermissionScope {
|
||||
readonly operation_identity: string;
|
||||
}
|
||||
|
||||
/** Administrator-owned maximum authority for one capability. */
|
||||
export type PermissionCeiling = PermissionScope;
|
||||
|
||||
/** Authority issued beneath the administrator ceiling. */
|
||||
export interface PermissionGrant extends PermissionScope {
|
||||
readonly grant_id: string;
|
||||
readonly lifetime: "once" | "session" | "project" | "administrator";
|
||||
readonly operation_identity: string | null;
|
||||
readonly expires_at: string | null;
|
||||
}
|
||||
|
||||
/** Missing requested authority, containing names and paths but never values. */
|
||||
export interface MissingPermissionScope {
|
||||
readonly roots?: readonly string[];
|
||||
readonly executables?: readonly string[];
|
||||
readonly environment_names?: readonly string[];
|
||||
readonly origins?: readonly string[];
|
||||
readonly network?: PermissionNetwork;
|
||||
readonly mount?: true;
|
||||
}
|
||||
|
||||
/** Immutable shared policy state. */
|
||||
export interface PermissionPolicy {
|
||||
readonly ceilings: readonly PermissionCeiling[];
|
||||
readonly grants: readonly PermissionGrant[];
|
||||
readonly revoked_grant_ids: ReadonlySet<string>;
|
||||
readonly consumed_grant_ids: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/** A proposed grant exceeds the administrator-owned maximum authority. */
|
||||
export class PermissionGrantError extends Error {
|
||||
readonly _tag = "PermissionGrantError" as const;
|
||||
|
||||
constructor(
|
||||
readonly reason: "outside_administrator_ceiling" | "invalid_once_scope",
|
||||
readonly missing: MissingPermissionScope,
|
||||
) {
|
||||
super(`Permission grant rejected: ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Observable decision returned before any governed side effect. */
|
||||
export type PermissionDecision =
|
||||
| {
|
||||
readonly allowed: true;
|
||||
readonly grant_id: string;
|
||||
readonly lifetime: PermissionGrant["lifetime"];
|
||||
}
|
||||
| {
|
||||
readonly allowed: false;
|
||||
readonly reason:
|
||||
| "outside_administrator_ceiling"
|
||||
| "grant_required"
|
||||
| "grant_revoked_or_consumed"
|
||||
| "grant_expired";
|
||||
readonly missing: MissingPermissionScope;
|
||||
};
|
||||
|
||||
/** Create a fail-closed policy with no implicit grants. */
|
||||
export const createPermissionPolicy = (
|
||||
ceilings: readonly PermissionCeiling[],
|
||||
grants: readonly PermissionGrant[] = [],
|
||||
): PermissionPolicy => ({
|
||||
ceilings: ceilings.map(normalizeScope),
|
||||
grants: grants.map(normalizeGrant),
|
||||
revoked_grant_ids: new Set(),
|
||||
consumed_grant_ids: new Set(),
|
||||
});
|
||||
|
||||
/** Add one grant only when it is a subset of its administrator ceiling. */
|
||||
export const grantPermission = (
|
||||
policy: PermissionPolicy,
|
||||
grant: PermissionGrant,
|
||||
): Result<PermissionPolicy, PermissionGrantError> => {
|
||||
const normalized = normalizeGrant(grant);
|
||||
if (normalized.lifetime === "once" && normalized.operation_identity === null)
|
||||
return err(new PermissionGrantError("invalid_once_scope", {}));
|
||||
const missing = missingFromScopes(normalized, policy.ceilings);
|
||||
if (hasMissing(missing))
|
||||
return err(
|
||||
new PermissionGrantError("outside_administrator_ceiling", missing),
|
||||
);
|
||||
return ok({ ...policy, grants: [...policy.grants, normalized] });
|
||||
};
|
||||
|
||||
/** Evaluate exact requested authority against the ceiling and active grants. */
|
||||
export const evaluatePermission = (
|
||||
policy: PermissionPolicy,
|
||||
request: PermissionRequest,
|
||||
now: Date = new Date(),
|
||||
): PermissionDecision => {
|
||||
const normalized = normalizeRequest(request);
|
||||
const outsideCeiling = missingFromScopes(normalized, policy.ceilings);
|
||||
if (hasMissing(outsideCeiling))
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "outside_administrator_ceiling",
|
||||
missing: outsideCeiling,
|
||||
};
|
||||
const candidates = policy.grants.filter(
|
||||
(grant) =>
|
||||
grant.capability === normalized.capability &&
|
||||
!policy.revoked_grant_ids.has(grant.grant_id) &&
|
||||
!policy.consumed_grant_ids.has(grant.grant_id) &&
|
||||
!isExpired(grant, now) &&
|
||||
(grant.lifetime !== "once" ||
|
||||
grant.operation_identity === normalized.operation_identity),
|
||||
);
|
||||
const selected = candidates.find(
|
||||
(grant) => !hasMissing(missingFromScopes(normalized, [grant])),
|
||||
);
|
||||
if (selected !== undefined)
|
||||
return {
|
||||
allowed: true,
|
||||
grant_id: selected.grant_id,
|
||||
lifetime: selected.lifetime,
|
||||
};
|
||||
const coveringGrant = policy.grants.find(
|
||||
(grant) =>
|
||||
grant.capability === normalized.capability &&
|
||||
(grant.lifetime !== "once" ||
|
||||
grant.operation_identity === normalized.operation_identity) &&
|
||||
!hasMissing(missingFromScopes(normalized, [grant])),
|
||||
);
|
||||
return {
|
||||
allowed: false,
|
||||
reason:
|
||||
coveringGrant !== undefined && isExpired(coveringGrant, now)
|
||||
? "grant_expired"
|
||||
: candidates.length === 0 &&
|
||||
policy.grants.some(
|
||||
(grant) => grant.capability === normalized.capability,
|
||||
)
|
||||
? "grant_revoked_or_consumed"
|
||||
: "grant_required",
|
||||
missing: missingFromScopes(normalized, candidates),
|
||||
};
|
||||
};
|
||||
|
||||
/** Consume an allowed one-shot decision; longer-lived grants are unchanged. */
|
||||
export const consumePermission = (
|
||||
policy: PermissionPolicy,
|
||||
decision: Extract<PermissionDecision, { readonly allowed: true }>,
|
||||
): PermissionPolicy =>
|
||||
decision.lifetime === "once"
|
||||
? {
|
||||
...policy,
|
||||
consumed_grant_ids: new Set([
|
||||
...policy.consumed_grant_ids,
|
||||
decision.grant_id,
|
||||
]),
|
||||
}
|
||||
: policy;
|
||||
|
||||
/** Revoke one grant for every future evaluation. */
|
||||
export const revokePermission = (
|
||||
policy: PermissionPolicy,
|
||||
grantId: string,
|
||||
): PermissionPolicy => ({
|
||||
...policy,
|
||||
revoked_grant_ids: new Set([...policy.revoked_grant_ids, grantId]),
|
||||
});
|
||||
|
||||
/** Drop connection-bound authority when an MCP or CLI session ends. */
|
||||
export const clearSessionPermissions = (
|
||||
policy: PermissionPolicy,
|
||||
): PermissionPolicy => ({
|
||||
...policy,
|
||||
grants: policy.grants.filter(
|
||||
({ lifetime }) => lifetime !== "once" && lifetime !== "session",
|
||||
),
|
||||
});
|
||||
|
||||
/** Atomically replace administrator ceilings while retaining auditable grants. */
|
||||
export const reloadPermissionCeilings = (
|
||||
policy: PermissionPolicy,
|
||||
ceilings: readonly PermissionCeiling[],
|
||||
): PermissionPolicy => ({
|
||||
...policy,
|
||||
ceilings: ceilings.map(normalizeScope),
|
||||
});
|
||||
|
||||
const normalizeScope = (scope: PermissionScope): PermissionScope => ({
|
||||
capability: scope.capability,
|
||||
roots: uniqueSorted(scope.roots.map((path) => resolve(path))),
|
||||
executables: uniqueSorted(scope.executables.map((path) => resolve(path))),
|
||||
environment_names: uniqueSorted(scope.environment_names),
|
||||
...(scope.origins === undefined
|
||||
? {}
|
||||
: { origins: uniqueSorted(scope.origins) }),
|
||||
network: scope.network,
|
||||
mount: scope.mount,
|
||||
});
|
||||
|
||||
const normalizeGrant = (grant: PermissionGrant): PermissionGrant => ({
|
||||
...normalizeScope(grant),
|
||||
grant_id: grant.grant_id,
|
||||
lifetime: grant.lifetime,
|
||||
operation_identity: grant.operation_identity,
|
||||
expires_at: grant.expires_at,
|
||||
});
|
||||
|
||||
const normalizeRequest = (request: PermissionRequest): PermissionRequest => ({
|
||||
...normalizeScope(request),
|
||||
operation_identity: request.operation_identity,
|
||||
});
|
||||
|
||||
const missingFromScopes = (
|
||||
request: PermissionScope,
|
||||
scopes: readonly PermissionScope[],
|
||||
): MissingPermissionScope => {
|
||||
const relevant = scopes.filter(
|
||||
({ capability }) => capability === request.capability,
|
||||
);
|
||||
const roots = request.roots.filter(
|
||||
(path) => !relevant.some((scope) => containsAny(scope.roots, path)),
|
||||
);
|
||||
const executables = request.executables.filter(
|
||||
(path) => !relevant.some((scope) => containsAny(scope.executables, path)),
|
||||
);
|
||||
const environmentNames = request.environment_names.filter(
|
||||
(name) => !relevant.some((scope) => scope.environment_names.includes(name)),
|
||||
);
|
||||
const origins = (request.origins ?? []).filter(
|
||||
(origin) =>
|
||||
!relevant.some((scope) => (scope.origins ?? []).includes(origin)),
|
||||
);
|
||||
const network =
|
||||
request.network === "none" ||
|
||||
relevant.some(
|
||||
(scope) => networkRank(scope.network) >= networkRank(request.network),
|
||||
)
|
||||
? undefined
|
||||
: request.network;
|
||||
const mount =
|
||||
!request.mount || relevant.some((scope) => scope.mount)
|
||||
? undefined
|
||||
: (true as const);
|
||||
return {
|
||||
...(roots.length === 0 ? {} : { roots }),
|
||||
...(executables.length === 0 ? {} : { executables }),
|
||||
...(environmentNames.length === 0
|
||||
? {}
|
||||
: { environment_names: environmentNames }),
|
||||
...(origins.length === 0 ? {} : { origins }),
|
||||
...(network === undefined ? {} : { network }),
|
||||
...(mount === undefined ? {} : { mount }),
|
||||
};
|
||||
};
|
||||
|
||||
interface PathOperations {
|
||||
readonly relative: typeof relative;
|
||||
readonly isAbsolute: typeof isAbsolute;
|
||||
readonly sep: string;
|
||||
}
|
||||
|
||||
const HOST_PATH_OPERATIONS: PathOperations = { relative, isAbsolute, sep };
|
||||
|
||||
/** Test whether a candidate is inside a root using one platform path flavor. */
|
||||
export const isPathContained = (
|
||||
root: string,
|
||||
candidate: string,
|
||||
operations: PathOperations = HOST_PATH_OPERATIONS,
|
||||
): boolean => {
|
||||
const relation = operations.relative(root, candidate);
|
||||
return (
|
||||
relation === "" ||
|
||||
(relation !== ".." &&
|
||||
!relation.startsWith(`..${operations.sep}`) &&
|
||||
!operations.isAbsolute(relation))
|
||||
);
|
||||
};
|
||||
|
||||
const containsAny = (roots: readonly string[], candidate: string): boolean =>
|
||||
roots.some((root) => isPathContained(root, candidate));
|
||||
|
||||
const networkRank = (network: PermissionNetwork): number => {
|
||||
switch (network) {
|
||||
case "none":
|
||||
return 0;
|
||||
case "loopback":
|
||||
return 1;
|
||||
case "external":
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
|
||||
const isExpired = (grant: PermissionGrant, now: Date): boolean =>
|
||||
grant.expires_at !== null && Date.parse(grant.expires_at) <= now.getTime();
|
||||
|
||||
const hasMissing = (missing: MissingPermissionScope): boolean =>
|
||||
Object.keys(missing).length > 0;
|
||||
|
||||
const uniqueSorted = (values: readonly string[]): readonly string[] =>
|
||||
[...new Set(values)].sort((left, right) => left.localeCompare(right));
|
||||
@@ -12,7 +12,6 @@ export const comparisonStatusSchema = z.enum([
|
||||
"changed",
|
||||
"truncated",
|
||||
"unknown",
|
||||
"contradiction",
|
||||
]);
|
||||
type ComparisonStatus = z.infer<typeof comparisonStatusSchema>;
|
||||
|
||||
|
||||
@@ -389,8 +389,7 @@ const classify = (
|
||||
unknowns: readonly ResidualUnknown[],
|
||||
): ClaimResult["status"] => {
|
||||
if (limitations.length > 0) return "unknown";
|
||||
if (["changed", "added", "removed", "contradiction"].includes(observed))
|
||||
return "fail";
|
||||
if (["changed", "added", "removed"].includes(observed)) return "fail";
|
||||
if (observed === "unknown" || observed === "truncated" || unknowns.length > 0)
|
||||
return "unknown";
|
||||
return "pass";
|
||||
|
||||
@@ -96,7 +96,6 @@ export const reconstructionClaimResultSchema = z.object({
|
||||
"changed",
|
||||
"truncated",
|
||||
"unknown",
|
||||
"contradiction",
|
||||
]),
|
||||
comparison_evidence_id: evidenceIdSchema,
|
||||
left_evidence_ids: z.array(evidenceIdSchema).min(1).max(100),
|
||||
|
||||
@@ -338,11 +338,7 @@ const selectStatic = (
|
||||
return {
|
||||
status,
|
||||
conclusion_kind:
|
||||
status === "contradiction"
|
||||
? "contradiction"
|
||||
: status === "unknown"
|
||||
? "unresolved_branch"
|
||||
: "derived_relationship",
|
||||
status === "unknown" ? "unresolved_branch" : "derived_relationship",
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
/** Generated from package.json and package-lock.json; do not edit. */
|
||||
export const PACKAGE_METADATA = {
|
||||
name: "rea-agents",
|
||||
version: "1.4.0",
|
||||
serverSdkVersion: "2.0.0-beta.4",
|
||||
clientSdkVersion: "2.0.0-beta.4",
|
||||
coreSdkVersion: "2.0.0-beta.4",
|
||||
skillVersion: "13",
|
||||
} as const;
|
||||
+12
-41
@@ -167,7 +167,6 @@ export class HopperClient {
|
||||
this.#closing = true;
|
||||
try {
|
||||
const socket = this.#socket;
|
||||
let cleanupAttempted = false;
|
||||
if (socket !== undefined && !socket.destroyed) {
|
||||
const shutdown = await this.#request(
|
||||
"shutdown",
|
||||
@@ -175,23 +174,12 @@ export class HopperClient {
|
||||
{ timeoutMs: SHUTDOWN_TIMEOUT_MS },
|
||||
).catch(() => err(new HopperProcessError(null)));
|
||||
if (
|
||||
shutdown.ok &&
|
||||
isCleanupRequired(shutdown.value) &&
|
||||
this.#launch?.shutdownByCleanup === true &&
|
||||
this.#launch.cleanup !== undefined
|
||||
) {
|
||||
cleanupAttempted = true;
|
||||
const cleanup = await this.#launch.cleanup();
|
||||
if (!cleanup.cleaned) {
|
||||
await this.#fallbackDocumentShutdown();
|
||||
const retried = await this.#launch.cleanup();
|
||||
if (!retried.cleaned)
|
||||
this.#logger.warn(
|
||||
{ reason: retried.reason },
|
||||
"Owned launcher cleanup failed closed",
|
||||
);
|
||||
}
|
||||
} else if (!shutdown.ok || !isShutdownAcknowledgement(shutdown.value))
|
||||
!shutdown.ok ||
|
||||
!isShutdownAcknowledgement(
|
||||
shutdown.value,
|
||||
this.#launch?.shutdownByCleanup === true,
|
||||
)
|
||||
)
|
||||
this.#logger.warn(
|
||||
{ status: shutdown.ok ? "invalid-acknowledgement" : "failed" },
|
||||
"Hopper document shutdown was not confirmed",
|
||||
@@ -201,7 +189,7 @@ export class HopperClient {
|
||||
socket?.destroy();
|
||||
this.#socket = undefined;
|
||||
if (this.#launch?.ownsProcessLifetime === true) {
|
||||
if (this.#launch.cleanup !== undefined && !cleanupAttempted) {
|
||||
if (this.#launch.cleanup !== undefined) {
|
||||
const cleanup = await this.#launch.cleanup();
|
||||
if (!cleanup.cleaned)
|
||||
this.#logger.warn(
|
||||
@@ -223,19 +211,6 @@ export class HopperClient {
|
||||
}
|
||||
}
|
||||
|
||||
async #fallbackDocumentShutdown(): Promise<void> {
|
||||
const fallback = await this.#request(
|
||||
"shutdown_document",
|
||||
{},
|
||||
{ timeoutMs: SHUTDOWN_TIMEOUT_MS },
|
||||
).catch(() => err(new HopperProcessError(null)));
|
||||
if (!fallback.ok || !isShutdownAcknowledgement(fallback.value))
|
||||
this.#logger.warn(
|
||||
{ status: fallback.ok ? "invalid-acknowledgement" : "failed" },
|
||||
"Hopper document shutdown fallback was not confirmed",
|
||||
);
|
||||
}
|
||||
|
||||
async #connect(
|
||||
socketPath: string,
|
||||
signal?: AbortSignal,
|
||||
@@ -443,22 +418,18 @@ const connectOnce = async (
|
||||
});
|
||||
});
|
||||
|
||||
const isShutdownAcknowledgement = (value: JsonValue): boolean => {
|
||||
const isShutdownAcknowledgement = (
|
||||
value: JsonValue,
|
||||
ownsProcessLifetime: boolean,
|
||||
): boolean => {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value))
|
||||
return false;
|
||||
if (value.shutdown !== true) return false;
|
||||
if (value.analysis_stopped === true && value.document_closed === true)
|
||||
return true;
|
||||
return false;
|
||||
return ownsProcessLifetime && value.cleanup_required === true;
|
||||
};
|
||||
|
||||
const isCleanupRequired = (value: JsonValue): boolean =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
value.shutdown === true &&
|
||||
value.cleanup_required === true;
|
||||
|
||||
const parseServerInfo = (
|
||||
value: JsonValue,
|
||||
expectedRunId: string,
|
||||
|
||||
+3
-11
@@ -5,20 +5,12 @@
|
||||
*/
|
||||
export const PRODUCT_IDENTITY = {
|
||||
displayName: "REA",
|
||||
packageName: PACKAGE_METADATA.name,
|
||||
packageVersion: PACKAGE_METADATA.version,
|
||||
packageName: "rea-agents",
|
||||
packageVersion: "1.1.0",
|
||||
cliBinary: "rea",
|
||||
mcpCommand: "npx -y rea-agents mcp",
|
||||
mcpServerKey: "rea",
|
||||
skillName: "rea-analysis",
|
||||
skillVersion: PACKAGE_METADATA.skillVersion,
|
||||
skillVersion: "10",
|
||||
configFileName: "rea.json",
|
||||
} as const;
|
||||
|
||||
/** Exact SDK package identities, distinct from negotiated protocol version. */
|
||||
export const SDK_IDENTITY = {
|
||||
server: PACKAGE_METADATA.serverSdkVersion,
|
||||
client_test: PACKAGE_METADATA.clientSdkVersion,
|
||||
core: PACKAGE_METADATA.coreSdkVersion,
|
||||
} as const;
|
||||
import { PACKAGE_METADATA } from "./generatedPackageMetadata.js";
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@ const logLevelSchema = z
|
||||
export const parseLogLevel = (value: unknown): LogLevel =>
|
||||
logLevelSchema.parse(value);
|
||||
|
||||
/** Create a JSON logger whose stderr destination cannot corrupt structured output. */
|
||||
/** Create a JSON logger whose destination cannot corrupt MCP stdout frames. */
|
||||
export const createLogger = (mode: "mcp" | "cli", level: LogLevel): Logger =>
|
||||
pino(
|
||||
{
|
||||
@@ -32,7 +32,7 @@ export const createLogger = (mode: "mcp" | "cli", level: LogLevel): Logger =>
|
||||
censor: "[Redacted]",
|
||||
},
|
||||
},
|
||||
pino.destination({ dest: 2, sync: false }),
|
||||
pino.destination({ dest: mode === "mcp" ? 2 : 1, sync: false }),
|
||||
);
|
||||
|
||||
/** Logger used by embedders that have not opted into process output. */
|
||||
|
||||
+7
-138
@@ -13,9 +13,6 @@ import { createBinarySession } from "./application/runtime.js";
|
||||
import { createServer } from "./server/createServer.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { projectAnalysisError } from "./domain/errors.js";
|
||||
import { readProjectPermissionStore } from "./application/ProjectPermissionStore.js";
|
||||
import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js";
|
||||
import { CdpBrowserProvider } from "./browser/CdpBrowserProvider.js";
|
||||
|
||||
const MCP_CONNECTION_LOST =
|
||||
"REA lost its MCP client connection. Restart REA from your MCP client.";
|
||||
@@ -33,7 +30,6 @@ interface RuntimeDependencies {
|
||||
readonly writeStderr: (text: string) => void;
|
||||
readonly setExitCode: (code: number) => void;
|
||||
readonly registerShutdown: (handler: () => void) => void;
|
||||
readonly registerReload?: (handler: () => void) => () => void;
|
||||
}
|
||||
|
||||
const runtimeDependencies = (): RuntimeDependencies => ({
|
||||
@@ -49,10 +45,6 @@ const runtimeDependencies = (): RuntimeDependencies => ({
|
||||
process.stdin.once("end", handler);
|
||||
process.stdin.once("close", handler);
|
||||
},
|
||||
registerReload: (handler) => {
|
||||
process.on("SIGHUP", handler);
|
||||
return () => process.off("SIGHUP", handler);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -70,18 +62,8 @@ export const run = async (
|
||||
}
|
||||
const logger = createLogger("mcp", config.value.logLevel);
|
||||
const serverLogger = logger.child({ layer: "server" });
|
||||
const permissionAuthority = await loadConfiguredPermissionAuthority(
|
||||
config.value,
|
||||
);
|
||||
if (!permissionAuthority.ok) {
|
||||
dependencies.writeStderr(
|
||||
`${projectAnalysisError(permissionAuthority.error).message}\n`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const session = createBinarySession(config.value, logger);
|
||||
const browserObservation = new CdpBrowserProvider();
|
||||
if (config.value.hopperTargetPath !== undefined) {
|
||||
const opened = await session.open(config.value.hopperTargetPath, {
|
||||
targetKind: config.value.hopperTargetKind,
|
||||
@@ -98,51 +80,17 @@ export const run = async (
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
let currentConfig = config.value;
|
||||
const runtimeProcessPolicy = {
|
||||
...config.value.processExecutionPolicy,
|
||||
executableRoots: [...config.value.processExecutionPolicy.executableRoots],
|
||||
workingRoots: [...config.value.processExecutionPolicy.workingRoots],
|
||||
allowedEnvironment: [
|
||||
...config.value.processExecutionPolicy.allowedEnvironment,
|
||||
],
|
||||
};
|
||||
const runtimeEvidencePolicy = {
|
||||
...config.value.evidenceFilePolicy,
|
||||
roots: [...config.value.evidenceFilePolicy.roots],
|
||||
};
|
||||
const runtimeSnapshotPolicy = {
|
||||
...config.value.analysisSnapshotFilePolicy,
|
||||
roots: [...config.value.analysisSnapshotFilePolicy.roots],
|
||||
};
|
||||
const runtimeInvestigationRoots = [...config.value.investigationInputRoots];
|
||||
const liveServers = new Set<ReturnType<typeof createServer>>();
|
||||
let handle: StdioServerHandle;
|
||||
try {
|
||||
handle = dependencies.serve(
|
||||
() => {
|
||||
const server = createServer(session, session, {
|
||||
() =>
|
||||
createServer(session, session, {
|
||||
logger,
|
||||
processPolicy: runtimeProcessPolicy,
|
||||
evidenceFilePolicy: runtimeEvidencePolicy,
|
||||
investigationInputRoots: runtimeInvestigationRoots,
|
||||
analysisSnapshotFilePolicy: runtimeSnapshotPolicy,
|
||||
permissionAuthority: permissionAuthority.value,
|
||||
browserObservation,
|
||||
artifactIntegrityContinueEnabled: () =>
|
||||
currentConfig.artifactIntegrityContinueEnabled,
|
||||
availabilityPolicy: () => ({
|
||||
processCaptureEnabled: currentConfig.processExecutionPolicy.enabled,
|
||||
evidenceFileRoots: currentConfig.evidenceFilePolicy.roots.length,
|
||||
browserObservationEnabled:
|
||||
currentConfig.browserObservationEnabled &&
|
||||
currentConfig.browserCdpEndpoints.length > 0 &&
|
||||
currentConfig.browserAllowedOrigins.length > 0,
|
||||
}),
|
||||
});
|
||||
liveServers.add(server);
|
||||
return server;
|
||||
},
|
||||
processPolicy: config.value.processExecutionPolicy,
|
||||
evidenceFilePolicy: config.value.evidenceFilePolicy,
|
||||
investigationInputRoots: config.value.investigationInputRoots,
|
||||
analysisSnapshotFilePolicy: config.value.analysisSnapshotFilePolicy,
|
||||
}),
|
||||
{
|
||||
onerror: () => {
|
||||
serverLogger.error(MCP_CONNECTION_LOST);
|
||||
@@ -157,90 +105,11 @@ export const run = async (
|
||||
return 1;
|
||||
}
|
||||
|
||||
const unregisterReload =
|
||||
dependencies.registerReload?.(() => {
|
||||
void (async () => {
|
||||
const refreshed = parseConfig(dependencies.env);
|
||||
if (!refreshed.ok) {
|
||||
serverLogger.error("Reloaded permission policy is invalid");
|
||||
return;
|
||||
}
|
||||
const reloaded = await permissionAuthority.value.reload(
|
||||
refreshed.value.permissionCeilings,
|
||||
);
|
||||
if (!reloaded.ok) {
|
||||
serverLogger.error(
|
||||
"Reloaded permission ceiling could not be applied",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const administrators =
|
||||
await permissionAuthority.value.replaceAdministratorGrants(
|
||||
refreshed.value.administratorPermissionGrants,
|
||||
);
|
||||
if (!administrators.ok) {
|
||||
serverLogger.error(
|
||||
"Reloaded administrator grants could not be applied",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
refreshed.value.permissionProjectRoot !== undefined &&
|
||||
refreshed.value.permissionProjectStore !== undefined
|
||||
) {
|
||||
const project = await readProjectPermissionStore(
|
||||
refreshed.value.permissionProjectStore,
|
||||
refreshed.value.permissionProjectRoot,
|
||||
);
|
||||
if (!project.ok) {
|
||||
serverLogger.error("Reloaded project grants could not be read");
|
||||
return;
|
||||
}
|
||||
const projects = await permissionAuthority.value.replaceProjectGrants(
|
||||
project.value?.grants ?? [],
|
||||
);
|
||||
if (!projects.ok) {
|
||||
serverLogger.error("Reloaded project grants could not be applied");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const projects = await permissionAuthority.value.replaceProjectGrants(
|
||||
[],
|
||||
);
|
||||
if (!projects.ok) {
|
||||
serverLogger.error("Reloaded project grants could not be cleared");
|
||||
return;
|
||||
}
|
||||
}
|
||||
currentConfig = refreshed.value;
|
||||
Object.assign(
|
||||
runtimeProcessPolicy,
|
||||
refreshed.value.processExecutionPolicy,
|
||||
);
|
||||
Object.assign(
|
||||
runtimeEvidencePolicy,
|
||||
refreshed.value.evidenceFilePolicy,
|
||||
);
|
||||
Object.assign(
|
||||
runtimeSnapshotPolicy,
|
||||
refreshed.value.analysisSnapshotFilePolicy,
|
||||
);
|
||||
runtimeInvestigationRoots.splice(
|
||||
0,
|
||||
runtimeInvestigationRoots.length,
|
||||
...refreshed.value.investigationInputRoots,
|
||||
);
|
||||
for (const server of liveServers) server.sendToolListChanged();
|
||||
})();
|
||||
}) ?? (() => undefined);
|
||||
|
||||
let shutdownPromise: Promise<void> | undefined;
|
||||
const shutdown = (): Promise<void> => {
|
||||
shutdownPromise ??= (async () => {
|
||||
unregisterReload();
|
||||
await handle.close();
|
||||
await session.close();
|
||||
permissionAuthority.value.clearSessionGrants();
|
||||
})();
|
||||
return shutdownPromise;
|
||||
};
|
||||
|
||||
@@ -12,11 +12,6 @@ import { silentLogger, type Logger } from "../logger.js";
|
||||
import type { ProcessExecutionPolicy } from "../domain/processCapture.js";
|
||||
import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js";
|
||||
import { registerGuidedPrompts } from "./registerPrompts.js";
|
||||
import { registerEvidenceResources } from "./registerEvidenceResources.js";
|
||||
import { createServerIdentity } from "../serverIdentity.js";
|
||||
import type { PermissionAuthority } from "../application/PermissionAuthority.js";
|
||||
import type { BrowserObservationPort } from "../application/BrowserObservationPort.js";
|
||||
import { registerBrowserTools } from "./registerBrowserTools.js";
|
||||
|
||||
export interface CreateServerOptions {
|
||||
readonly logger?: Logger;
|
||||
@@ -24,14 +19,6 @@ export interface CreateServerOptions {
|
||||
readonly evidenceFilePolicy?: EvidenceFilePolicy;
|
||||
readonly investigationInputRoots?: readonly string[];
|
||||
readonly analysisSnapshotFilePolicy?: EvidenceFilePolicy;
|
||||
readonly permissionAuthority?: PermissionAuthority;
|
||||
readonly browserObservation?: BrowserObservationPort;
|
||||
readonly artifactIntegrityContinueEnabled?: () => boolean;
|
||||
readonly availabilityPolicy?: () => {
|
||||
readonly processCaptureEnabled: boolean;
|
||||
readonly evidenceFileRoots: number;
|
||||
readonly browserObservationEnabled?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,71 +31,25 @@ export const createServer = (
|
||||
session?: BinarySessionPort,
|
||||
options: CreateServerOptions = {},
|
||||
): McpServer => {
|
||||
const startedAt = new Date().toISOString();
|
||||
const logger = options.logger ?? silentLogger;
|
||||
const server = new McpServer(
|
||||
{ name: PRODUCT_IDENTITY.mcpServerKey, version: "0.1.0" },
|
||||
{
|
||||
name: PRODUCT_IDENTITY.mcpServerKey,
|
||||
version: PRODUCT_IDENTITY.packageVersion,
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: { listChanged: true },
|
||||
resources: { listChanged: true },
|
||||
},
|
||||
capabilities: { tools: {} },
|
||||
instructions:
|
||||
session === undefined
|
||||
? "Reverse-engineering tools for an active analysis target. Start with binary_overview."
|
||||
: "Reverse-engineering tools for configured analysis providers. Open a target with open_binary, then start with binary_overview.",
|
||||
},
|
||||
);
|
||||
session?.onAvailabilityChanged?.(() => server.sendToolListChanged());
|
||||
server.registerResource(
|
||||
"server-identity",
|
||||
"rea://server/identity",
|
||||
{
|
||||
title: "REA server identity",
|
||||
description: "Live package, SDK, protocol, and catalog identity.",
|
||||
mimeType: "application/json",
|
||||
},
|
||||
(uri) => {
|
||||
const client = server.server.getClientVersion();
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: uri.href,
|
||||
mimeType: "application/json",
|
||||
text: JSON.stringify(
|
||||
createServerIdentity({
|
||||
startedAt,
|
||||
...(client === undefined ? {} : { client }),
|
||||
...(server.server.getNegotiatedProtocolVersion() === undefined
|
||||
? {}
|
||||
: {
|
||||
protocolVersion:
|
||||
server.server.getNegotiatedProtocolVersion(),
|
||||
}),
|
||||
}),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
const toolLogger = logger.child({ layer: "server" });
|
||||
const activeTarget =
|
||||
session === undefined ? undefined : () => session.activeTarget();
|
||||
const recordEvidence =
|
||||
session === undefined
|
||||
? undefined
|
||||
: (evidence: Parameters<typeof session.recordEvidence>[0]) => {
|
||||
const recorded = session.recordEvidence(evidence);
|
||||
if (recorded.ok && recorded.value === "added")
|
||||
server.sendResourceListChanged();
|
||||
return recorded;
|
||||
};
|
||||
: (evidence: Parameters<typeof session.recordEvidence>[0]) =>
|
||||
session.recordEvidence(evidence);
|
||||
registerOfficialTools(server, analysis, {
|
||||
logger: toolLogger,
|
||||
activeTarget,
|
||||
@@ -136,23 +77,9 @@ export const createServer = (
|
||||
logger: toolLogger,
|
||||
activeTarget,
|
||||
recordEvidence,
|
||||
...(options.permissionAuthority === undefined
|
||||
? {}
|
||||
: { permissionAuthority: options.permissionAuthority }),
|
||||
});
|
||||
registerBrowserTools(server, {
|
||||
logger: toolLogger,
|
||||
browser: options.browserObservation,
|
||||
permissionAuthority: options.permissionAuthority,
|
||||
recordEvidence,
|
||||
});
|
||||
registerGuidedPrompts(server, analysis, session);
|
||||
if (session !== undefined) {
|
||||
registerEvidenceResources(server, session);
|
||||
registerSessionTools(server, session, toolLogger, {
|
||||
...options,
|
||||
startedAt,
|
||||
});
|
||||
}
|
||||
if (session !== undefined)
|
||||
registerSessionTools(server, session, toolLogger, options);
|
||||
return server;
|
||||
};
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import type { ProgressReporter } from "../application/ProgressReporter.js";
|
||||
import {
|
||||
createProgressReporter,
|
||||
silentProgressReporter,
|
||||
} from "../application/ProgressReporter.js";
|
||||
|
||||
interface McpProgressContext {
|
||||
readonly mcpReq: {
|
||||
readonly _meta?: { readonly progressToken?: string | number | undefined };
|
||||
notify(notification: {
|
||||
readonly method: "notifications/progress";
|
||||
readonly params: {
|
||||
readonly progressToken: string | number;
|
||||
readonly progress: number;
|
||||
readonly total?: number;
|
||||
readonly message?: string;
|
||||
};
|
||||
}): Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
/** Adapt the request's negotiated MCP progress token to application progress. */
|
||||
export const mcpProgressReporter = (
|
||||
context: McpProgressContext,
|
||||
): ProgressReporter => {
|
||||
const token = context.mcpReq._meta?.progressToken;
|
||||
if (token === undefined) return silentProgressReporter;
|
||||
return createProgressReporter(
|
||||
async (update) => {
|
||||
try {
|
||||
await context.mcpReq.notify({
|
||||
method: "notifications/progress",
|
||||
params: {
|
||||
progressToken: token,
|
||||
progress: update.completed,
|
||||
...(update.total === null ? {} : { total: update.total }),
|
||||
message: `${update.phase}: ${update.message}`,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Progress is observational; transport failure cannot change tool truth.
|
||||
}
|
||||
},
|
||||
{ minimumIntervalMs: 100 },
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user