Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f3917340b | ||
|
|
170e92929d | ||
|
|
ea953e6984 | ||
|
|
1dbb15734f | ||
|
|
a0f8b72b7a | ||
|
|
095c75df23 | ||
|
|
9cdbfd7dc0 | ||
|
|
1c9e50814e | ||
|
|
449afb176e | ||
|
|
f904668fbc | ||
|
|
4f62695a5c | ||
|
|
b9217dcd74 | ||
|
|
9bd2ac590c | ||
|
|
1bd65f68bc | ||
|
|
83b3368d8d | ||
|
|
9f9f9c3968 | ||
|
|
5a76aaffde | ||
|
|
6039b92be7 | ||
|
|
24b3dc41df | ||
|
|
f0069cbee4 | ||
|
|
7b1e3bda00 | ||
|
|
eeb4349f1f | ||
|
|
e79e36785b | ||
|
|
00abf45d20 | ||
|
|
c9500d32f6 | ||
|
|
4e8aa98267 | ||
|
|
ca035a75eb | ||
|
|
fd25a7e15a | ||
|
|
94a2c6013d | ||
|
|
9349ff5921 | ||
|
|
ec3254affa | ||
|
|
f29f683423 | ||
|
|
035e1144b0 | ||
|
|
38ff7418ef | ||
|
|
9d48ca788c | ||
|
|
38c883bbc8 | ||
|
|
697be942e8 | ||
|
|
8271650945 | ||
|
|
f27dd745b8 | ||
|
|
1ac3118aa2 | ||
|
|
d9977286f9 | ||
|
|
8e7709d35a | ||
|
|
205bf1529b | ||
|
|
1546a8db02 | ||
|
|
62ac73cdd7 | ||
|
|
9c3aec8131 | ||
|
|
13f3372a8b | ||
|
|
42fbbb297b | ||
|
|
0b7013134c | ||
|
|
e0bc7fd973 | ||
|
|
161377a505 | ||
|
|
732d606609 | ||
|
|
c9bd91d7c3 | ||
|
|
e698c977ed | ||
|
|
31a3cfa7e9 | ||
|
|
829d9a0933 | ||
|
|
9da179425e | ||
|
|
b906defbff | ||
|
|
6e6cce1bf3 | ||
|
|
69761e95b6 |
@@ -82,7 +82,7 @@ jobs:
|
||||
|
||||
test-shard:
|
||||
name: Test shard (${{ matrix.shard }})
|
||||
needs: changes
|
||||
needs: [changes, build]
|
||||
if: github.event_name == 'push' || needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
@@ -96,7 +96,11 @@ jobs:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run test:ci:shard -- --shard=${{ matrix.shard }}
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rea-dist
|
||||
path: dist
|
||||
- run: npm run test:ci:shard:run -- --shard=${{ matrix.shard }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
@@ -107,6 +111,26 @@ jobs:
|
||||
overwrite: true
|
||||
retention-days: 1
|
||||
|
||||
build:
|
||||
name: Build package
|
||||
needs: changes
|
||||
if: github.event_name == 'push' || needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run build:cached
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rea-dist
|
||||
path: dist
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
test:
|
||||
name: Test (coverage + timing)
|
||||
needs: [changes, test-shard]
|
||||
@@ -164,6 +188,51 @@ jobs:
|
||||
- run: npm ci
|
||||
- run: npm run verify:package
|
||||
|
||||
reconstruction-readiness:
|
||||
name: Reconstruction readiness
|
||||
needs: changes
|
||||
if: github.event_name == 'push' || needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Run the public CLI/MCP reconstruction journey
|
||||
env:
|
||||
REA_READINESS_REPORT_PATH: readiness-report.json
|
||||
run: |
|
||||
set -o pipefail
|
||||
npm run verify:readiness | tee readiness-summary.json
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: reconstruction-readiness
|
||||
path: |
|
||||
readiness-summary.json
|
||||
readiness-report.json
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
javascript-runtime-observation:
|
||||
name: JavaScript runtime observation
|
||||
needs: changes
|
||||
if: github.event_name == 'push' || needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Verify real Node Inspector discovery and observation
|
||||
run: npm run verify:inspector
|
||||
|
||||
windows-curated:
|
||||
name: Windows Ghidra P0 and package lane
|
||||
needs: changes
|
||||
|
||||
@@ -37,6 +37,7 @@ coverage/
|
||||
.vitest-reports/
|
||||
test-results.xml
|
||||
.turbo/
|
||||
.cache/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "2.3.0"
|
||||
".": "2.5.0"
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ See [docs/architecture.mermaid](docs/architecture.mermaid) for a visual architec
|
||||
- `src/contracts/`: caller-visible tool schemas and catalog metadata; `toolContracts.ts` owns the canonical inventory and `enhancedInputs.ts` owns enhanced input parsing.
|
||||
- `src/process/`: provider-neutral process ownership and lifecycle primitives. It owns private runtime roots, session-assigned run identity and token-verified lineage, absolute startup deadlines, correlated request waits, bounded output capture, and TERM-to-KILL cleanup without defining any provider wire protocol.
|
||||
- `src/replay/`: Linux x64 controlled-JavaScript-replay adapter. It owns exact runtime closure inspection, Bubblewrap/seccomp/cgroup admission, the disposable worker, strict parent/worker protocol validation, and complete cleanup observation.
|
||||
- `src/browser/`: loopback CDP discovery, bounded WebSocket transport, exact-origin target authorization, and passive browser observation normalization.
|
||||
- `src/browser/`: loopback CDP/Inspector discovery, bounded WebSocket transport, exact-origin or canonical-root target authorization, passive browser/Electron and attach-only Node/Electron V8 observation, plus controlled Playwright scenarios with explicit launch/attach ownership and cleanup.
|
||||
- `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/ghidra/`: exact Ghidra 12.1.2/JDK 21 inspection, analysis-profile commitment, digest-bound target snapshots, isolated `analyzeHeadless` launch, authenticated Unix-socket or Windows loopback transport, bounded serial request queue, and strict inventory/function boundaries.
|
||||
@@ -41,7 +41,7 @@ See [docs/architecture.mermaid](docs/architecture.mermaid) for a visual architec
|
||||
- `tests/`: Vitest suite. `tests/fixtures/` holds reusable provider-process fixtures, source-owned synthetic JavaScript graph and Electron/Webpack/Rspack artifact trees, and the fake launcher, Hopper bridge, and CDP seams.
|
||||
- `scripts/verify-real-hopper.mjs`: real-Hopper end-to-end verifier.
|
||||
- `scripts/verify-real-ghidra.mjs`: real Linux Ghidra verifier for x86-64 debug/stripped ELF, AArch64 ELF, PE, and Mach-O function semantics plus complete cleanup.
|
||||
- `scripts/verify-real-ghidra-windows.mjs`: real Windows x64 Ghidra P0 verifier for the source-owned native PE fixture, all 18 admitted operations, digest linkage, loopback transport, and cleanup.
|
||||
- `scripts/verify-real-ghidra-windows.mjs`: real Windows x64 Ghidra P0 verifier for the source-owned native PE fixture, all 19 admitted operations, digest linkage, loopback transport, and cleanup.
|
||||
- `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`).
|
||||
|
||||
@@ -54,9 +54,10 @@ See [docs/architecture.mermaid](docs/architecture.mermaid) for a visual architec
|
||||
- `npm run build`: compile `src/` into `dist/`.
|
||||
- `npm run build:cached`: compile through Turbo and reuse a valid local build cache entry.
|
||||
- `npm test`: restore or build `dist/`, then run the Vitest suite once without coverage or retries locally.
|
||||
- `npm run test:fast`: run the parallel test project without the serial integration group.
|
||||
- `npm run test:fast`: run pure and subprocess test projects without the serial integration group.
|
||||
- `npm run test:changed`: run changed and related non-serial tests once.
|
||||
- `npm run test:integration`: run the serial filesystem, process, and CLI integration group.
|
||||
- `npm run test:watch`: watch tests affected by working-tree changes in the parallel project, without coverage.
|
||||
- `npm run test:watch`: watch changed and related non-serial tests, without coverage.
|
||||
- `npm run test:coverage`: run the complete suite with coverage; CI shards the suite and owns JUnit reporting.
|
||||
- `npm run typecheck`: run strict TypeScript checks without emitting files.
|
||||
- `npm run lint`: apply oxlint rules (complexity, max-lines, unused vars, and TypeScript-specific checks).
|
||||
@@ -73,7 +74,8 @@ See [docs/architecture.mermaid](docs/architecture.mermaid) for a visual architec
|
||||
- `npm run verify:hopper`: build and run the real-Hopper verifier with two distinct binaries.
|
||||
- `npm run verify:ghidra`: build and run the real-Ghidra verifier against `GHIDRA_INSTALL_DIR` and optional `GHIDRA_TARGET_PATH`.
|
||||
- `npm run verify:ghidra:windows`: build and run the real Windows Ghidra verifier against `GHIDRA_INSTALL_DIR` and the source-owned x64 PE fixture.
|
||||
- `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:browser`: build and run the real Chrome verifier against `REA_BROWSER_EXECUTABLE` or a platform-default Chrome-family executable, including browser-scenario CLI parity, attach disconnect-only ownership, launched-process termination, and temporary-profile cleanup.
|
||||
- `npm run verify:inspector`: build and run the real attach-only Node Inspector verifier through the public CLI.
|
||||
- `npm run verify:managed`: build and run the source-owned managed PE/CLI conformance verifier for artifact triage, member inspection, managed/native boundary declarations, token drift comparison, malformed metadata, non-managed degradation, managed application-graph projection, manifest-verifier self-test, and optional BYO ILSpy oracle checks; set `REA_MANAGED_APP_MANIFEST_PATH` to verify an operator-local managed app manifest, including optional graph/trace assertions, and set `REA_ILSPY_CMD_PATH` to an absolute `ilspycmd` path to run the real ILSpy reconstruction oracle.
|
||||
- `npm run evidence:generate`: regenerate the managed conformance manifest and Evidence v2 completion ledger from live verifier output.
|
||||
- `npm run evidence:check`: rerun managed conformance and fail on generated manifest, completion ledger, or bundled skill drift.
|
||||
@@ -101,9 +103,19 @@ Pre-commit hooks via Husky format then lint staged source files. Pre-push runs `
|
||||
- `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.
|
||||
- `REA_BROWSER_SCENARIO_ENABLED` (optional, default `false`): add controlled browser automation to the administrator ceiling.
|
||||
- `REA_BROWSER_SCENARIO_AUTO_GRANT` (optional, default `false`): issue the exact configured browser-automation ceiling as an administrator grant for trusted unattended use.
|
||||
- `REA_BROWSER_SCENARIO_EXECUTABLE_ROOTS_JSON` (optional): canonical executable roots approved for provider-owned browser launch.
|
||||
- `REA_BROWSER_SCENARIO_CDP_ENDPOINTS_JSON` (optional): literal-loopback CDP endpoints approved for external-browser attachment.
|
||||
- `REA_BROWSER_SCENARIO_ALLOWED_ORIGINS_JSON` (optional): exact HTTP(S) page origins approved for browser scenarios.
|
||||
- `REA_BROWSER_SCENARIO_ALLOWED_ENV_JSON` (optional): environment-variable names that scenario secret references may resolve.
|
||||
- `REA_ELECTRON_OBSERVE_ENABLED` (optional, default `false`): add passive Electron file-page observation authority to the administrator ceiling.
|
||||
- `REA_ELECTRON_CDP_ENDPOINTS_JSON` (optional): approved literal loopback Electron CDP HTTP endpoints.
|
||||
- `REA_ELECTRON_FILE_ROOTS_JSON` (optional): canonical filesystem roots approved for passive Electron page observation.
|
||||
- `REA_V8_INSPECTOR_OBSERVE_ENABLED` (optional, default `false`): add passive attach-only Node/Electron Inspector observation authority to the administrator ceiling.
|
||||
- `REA_V8_INSPECTOR_ENDPOINTS_JSON` (optional): approved literal-loopback Node/Electron Inspector HTTP endpoints.
|
||||
- `REA_V8_INSPECTOR_FILE_ROOTS_JSON` (optional): canonical filesystem roots approved for Inspector targets and script locations.
|
||||
- `REA_V8_INSPECTOR_ALLOWED_ORIGINS_JSON` (optional): exact HTTP(S) origins approved for Electron renderer Inspector targets and scripts.
|
||||
- `REA_INVESTIGATION_INPUT_ROOTS_JSON` (optional): canonical filesystem roots approved for static JavaScript/Electron application analysis and other investigation inputs.
|
||||
- `REA_JAVASCRIPT_REPLAY_ENABLED` (optional, default `false`): add controlled extracted-module execution authority to the administrator ceiling.
|
||||
- `REA_JAVASCRIPT_REPLAY_ROOTS_JSON` (optional): exact canonical source roots approved for controlled replay.
|
||||
|
||||
@@ -1,5 +1,93 @@
|
||||
# Changelog
|
||||
|
||||
## [2.5.0](https://github.com/morluto/rea/compare/rea-agents-2.4.0...rea-agents-2.5.0) (2026-07-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add runtime reconstruction roadmap workflows ([1dbb157](https://github.com/morluto/rea/commit/1dbb15734f484db5f4945bd1d1805d8763bfef0e))
|
||||
* **analysis:** add bounded memory and call tracing ([8e7709d](https://github.com/morluto/rea/commit/8e7709d35ae0ab49392418106b67c2eaa9ea82dc))
|
||||
* **artifacts:** add provider-neutral inspection ([d997728](https://github.com/morluto/rea/commit/d9977286f95090b5db3afab78573fa29841f7e90))
|
||||
* **browser:** capture controlled Playwright scenarios ([1ac3118](https://github.com/morluto/rea/commit/1ac3118aa29d846c266edd1e8cd504c4707cf4df))
|
||||
* **browser:** compare scenario and storage evidence ([f27dd74](https://github.com/morluto/rea/commit/f27dd745b8e398bba544a97374eed9a45aa4b688))
|
||||
* **javascript:** compare source with shipped bundles ([697be94](https://github.com/morluto/rea/commit/697be942e8780f3b94aaa6e9fd37432c0bb31b47))
|
||||
* **javascript:** recover runtime semantic effects ([38c883b](https://github.com/morluto/rea/commit/38c883bbc8a8d059ef21cdd85272e6f31a9ee45c))
|
||||
* **process:** support explicit stdin closure ([205bf15](https://github.com/morluto/rea/commit/205bf1529bf0679095f817ef2bc82c9e8166a669))
|
||||
* **reconstruction:** add obligation ledgers ([9d48ca7](https://github.com/morluto/rea/commit/9d48ca788cacabb5bc329e9cc0467784359c5d6d))
|
||||
* **reconstruction:** evaluate end-to-end readiness ([38ff741](https://github.com/morluto/rea/commit/38ff7418efc3299150f3a8a1eab65e8314ef7aa3))
|
||||
* **runtime:** add passive V8 Inspector observation ([8271650](https://github.com/morluto/rea/commit/82716509451fff1d0b1f91d8e9479b9c7d94ec20))
|
||||
* **server:** advertise available tools dynamically ([035e114](https://github.com/morluto/rea/commit/035e1144b0295434c6f3c7295d4b576ef72de396))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **analysis:** report call-edge truncation ([6039b92](https://github.com/morluto/rea/commit/6039b92be7af50e9f94095b4c6b375293ae38df4))
|
||||
* **browser:** close scenario containment gaps ([1bd65f6](https://github.com/morluto/rea/commit/1bd65f68bc632345b74a62bffe4394c84415a7f2))
|
||||
* **browser:** contain scenario pages and preserve inputs ([24b3dc4](https://github.com/morluto/rea/commit/24b3dc41dff1862405d6926aa618a60d06c737b1))
|
||||
* **browser:** require storage fingerprint approval ([f0069cb](https://github.com/morluto/rea/commit/f0069cbee4551e7988cfc4865ccf03dda48dc0dd))
|
||||
* **ci:** allow slower real browser startup ([095c75d](https://github.com/morluto/rea/commit/095c75df2349b9da5b8a2222eecd8fec67149f0c))
|
||||
* **ci:** propagate readiness verifier failures ([9f9f9c3](https://github.com/morluto/rea/commit/9f9f9c39688f9201fbb5dfa2826e576873c2e3d8))
|
||||
* **ci:** repair cross-platform package verification ([1c9e508](https://github.com/morluto/rea/commit/1c9e50814e3e3a7797c463e00e06afeb6afd0e20))
|
||||
* **comparison:** preserve uncertainty and v1 inputs ([b9217dc](https://github.com/morluto/rea/commit/b9217dcd746a842f92c0ae0012b35fb8ab9fa310))
|
||||
* **hopper:** add bounded analysis recovery ([e0bc7fd](https://github.com/morluto/rea/commit/e0bc7fd973b26448fb2192a2059ab1229bec0817))
|
||||
* **javascript:** keep digestless bundle matches unknown ([5a76aaf](https://github.com/morluto/rea/commit/5a76aaffde3c728f8984e917ff53aa2a628f5941))
|
||||
* **process:** expose launcher identity mismatch ([62ac73c](https://github.com/morluto/rea/commit/62ac73cdd79bee708d50b17a2dde8c23e0b66f1e))
|
||||
* **process:** normalize macOS launcher identity ([9c3aec8](https://github.com/morluto/rea/commit/9c3aec81317cd6caf627f13ebb0c6dc0f6735516))
|
||||
* **process:** tolerate exit during ownership revalidation ([a0f8b72](https://github.com/morluto/rea/commit/a0f8b72b7a4c90184390d5412690d3df20da047d))
|
||||
* **reconstruction:** authenticate obligation proof boundaries ([7b1e3bd](https://github.com/morluto/rea/commit/7b1e3bda00e4f9737a8882983b8750345eb7e1e2))
|
||||
* **reconstruction:** bind proof evidence to claims ([83b3368](https://github.com/morluto/rea/commit/83b3368d8debf95df29095ef53a4112ca666bcb2))
|
||||
* **runtime:** preserve deterministic context identity ([9bd2ac5](https://github.com/morluto/rea/commit/9bd2ac590c00703826283ef11b35a0ba2149bfa1))
|
||||
* **test:** use direct Node launcher on macOS ([13f3372](https://github.com/morluto/rea/commit/13f3372a8beaa165473bd7ecaccaa2997dcbf2ab))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **test:** speed up local and CI validation ([1546a8d](https://github.com/morluto/rea/commit/1546a8db029a56ed705e5246a6bf7020227234ce))
|
||||
* **test:** speed up local and CI validation ([42fbbb2](https://github.com/morluto/rea/commit/42fbbb297b8f40bcfbbfa219a1de098f3d0780ad))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **application:** reuse graph evidence resolution ([4e8aa98](https://github.com/morluto/rea/commit/4e8aa98267bb837033bf0ced58afc52d24f42492))
|
||||
* **browser:** centralize scenario metadata budgeting ([e79e367](https://github.com/morluto/rea/commit/e79e36785b51cf8c27a27516280a8f83a9ebd2cc))
|
||||
* **browser:** clarify inspector capture identifiers ([ca035a7](https://github.com/morluto/rea/commit/ca035a75eb6b7d1a79a928cdde572b9cb1df0d54))
|
||||
* **browser:** reuse CDP endpoint parsing ([00abf45](https://github.com/morluto/rea/commit/00abf45d2085464fd6090a44f786a53669111177))
|
||||
* **javascript:** centralize source range comparison ([c9500d3](https://github.com/morluto/rea/commit/c9500d32f69e8076255d4206203a41e39bab8dcb))
|
||||
* **javascript:** share semantic call-site lookup ([94a2c60](https://github.com/morluto/rea/commit/94a2c6013da122899f3d9aefbe5a0bf608033076))
|
||||
* **reconstruction:** isolate ledger coverage ([4f62695](https://github.com/morluto/rea/commit/4f62695a5cfc9e627387b0a04c1cebe253debbc8))
|
||||
* **server:** reuse availability policy type ([eeb4349](https://github.com/morluto/rea/commit/eeb4349f1f130b2bde9b0f89382d36a66553cbf1))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* document roadmap analysis workflows ([f29f683](https://github.com/morluto/rea/commit/f29f6834230fd0d3850c558c28af8a43746533c5))
|
||||
* refresh generated roadmap metadata ([f904668](https://github.com/morluto/rea/commit/f904668fbcba9a7866c6cde21e9628cfa2afe7aa))
|
||||
|
||||
|
||||
### Tests
|
||||
|
||||
* **browser:** use neutral scenario fixture identifiers ([fd25a7e](https://github.com/morluto/rea/commit/fd25a7e15aa2eba0bef01269e5061254b607bfdd))
|
||||
* **package:** stabilize fake Hopper ownership ([0b70131](https://github.com/morluto/rea/commit/0b7013134c58085b72177f2954e55534be053ebe))
|
||||
|
||||
|
||||
### Continuous Integration
|
||||
|
||||
* verify runtime observation and readiness ([9349ff5](https://github.com/morluto/rea/commit/9349ff5921fbd1bc7ac8b8d1d27bef0f36b76d92))
|
||||
|
||||
## [2.4.0](https://github.com/morluto/rea/compare/rea-agents-2.3.0...rea-agents-2.4.0) (2026-07-23)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **artifacts:** support MSIX and AppX packages ([6e6cce1](https://github.com/morluto/rea/commit/6e6cce1bf3cbd9c48799bee9969c53d66c42162e))
|
||||
* expand reactive capture and package analysis ([9da1794](https://github.com/morluto/rea/commit/9da179425e83df9f255597437320c1ab6ff5c5c2))
|
||||
* **process:** add reactive capture scenarios ([69761e9](https://github.com/morluto/rea/commit/69761e95b63ec7617aa161f7a1b4e3c8847a6175))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **capabilities:** expose typed availability codes ([b906def](https://github.com/morluto/rea/commit/b906defbff28885a1d39f78da7c2b7170a7108c6))
|
||||
|
||||
## [2.3.0](https://github.com/morluto/rea/compare/rea-agents-2.2.0...rea-agents-2.3.0) (2026-07-23)
|
||||
|
||||
|
||||
|
||||
+5
-3
@@ -39,9 +39,11 @@ prerequisite uses Turbo. CI uploads the rendered TypeDoc site as an `api-docs`
|
||||
artifact; the generated HTML is not committed.
|
||||
|
||||
Local `npm test` runs without coverage, retries, or verbose output. Use `npm run
|
||||
test:fast` for the parallel group, `npm run test:integration` for serial
|
||||
filesystem/process/CLI cases, and `npm run test:watch` to watch tests affected by
|
||||
working-tree changes. CI splits the complete suite across two native Vitest
|
||||
test:fast` for pure and subprocess groups, `npm run test:integration` for serial
|
||||
filesystem/process/CLI cases. `npm run test:changed` runs changed and related
|
||||
non-serial tests once; `npm run test:watch` keeps that selection live. Pure tests
|
||||
use Vitest threads, while subprocess tests stay in isolated forks. CI splits the
|
||||
complete suite across two native Vitest
|
||||
shards, then merges their coverage and JUnit reports. `npm run test:ci` runs the
|
||||
equivalent unsharded gate locally. Coverage thresholds remain in
|
||||
`vitest.config.ts`.
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
See a feature in an app that you want in your own product? Give the app to your agent—even without its source code. With REA, the agent can investigate the feature, explain how it works, show its evidence, and build a version adapted to your stack and requirements.
|
||||
|
||||
REA gives agents one consistent way to investigate software. Today that includes deep native analysis and function dossiers through Hopper or bring-your-own Ghidra on Linux, plus an experimental Windows x64 Ghidra P0 for approved native PE applications; execution-free managed PE/CLI triage; reproducible Evidence v2 records; controlled process capture; passive website and Electron observation; bounded JavaScript/source-map reconstruction; and a versioned domain graph for connecting JavaScript application layers without confusing static inference with runtime observation. The longer-term toolkit extends the same agent workflow to APIs, protocols, mobile artifacts, firmware, richer runtime behavior, and differences between versions.
|
||||
REA gives agents one consistent way to investigate software. Today that includes deep native analysis and function dossiers through Hopper or bring-your-own Ghidra on Linux, plus an experimental Windows x64 Ghidra P0 for approved native PE applications; execution-free managed PE/CLI triage; reproducible Evidence v2 records; controlled process capture; passive website, Electron page, and Node/Electron V8 Inspector observation; bounded JavaScript/source-map reconstruction; and a versioned domain graph for connecting JavaScript application layers without confusing static inference with runtime observation. The longer-term toolkit extends the same agent workflow to APIs, protocols, mobile artifacts, firmware, richer runtime behavior, and differences between versions.
|
||||
|
||||
Reverse engineering normally makes the operator choose a tool, learn its API, move evidence between programs, and decide what to inspect next. REA gives that work to the agent through commands, skills, structured results, and repeatable investigation workflows.
|
||||
|
||||
@@ -237,13 +237,13 @@ On Windows, set the same variables in PowerShell and run `rea doctor --json`; au
|
||||
|
||||
REA loads its packaged Java `HeadlessScript` with `-scriptPath`, copies and digest-verifies the target in an ephemeral runtime, enables `-readOnly` and `-deleteProject`, caps auto-analysis at 300 seconds with two CPUs and a 2 GiB Java heap, and authenticates every request. Linux uses a mode-0600 Unix socket. Windows P0 uses token-authenticated IPv4 loopback and a token-free endpoint record because Node path-based IPC does not connect to Java AF_UNIX sockets on Windows. The bridge verifies Ghidra's imported-byte SHA-256 before serving any operation.
|
||||
|
||||
The Ghidra adapter declares 18 direct and enhanced operations. Its ten inventory operations are `list_documents`, `list_procedures`, `list_strings`, `list_names`, `list_segments`, `address_name`, `procedure_address`, `resolve_containing_procedure`, `search_procedures`, and `search_strings`. It also admits `procedure_info`, `procedure_pseudo_code`, `procedure_assembly`, `procedure_callers`, `procedure_callees`, `procedure_references`, `xrefs`, and `analyze_function`. These capabilities enable the shared Swift/Objective-C inventory workflows, `binary_overview`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, `trace_feature`, and complete function dossiers. Default-space addresses are lowercase `0x` hexadecimal. Other spaces, including `EXTERNAL`, use `<percent-encoded-space>:0x<hex>`. Symbol results identify primary, dynamic, external, type, and source facts; procedures distinguish external functions and thunks; strings identify charset, missing-terminator state, byte length, and value truncation; memory-block ends are exclusive and permissions come directly from Ghidra.
|
||||
The Ghidra adapter declares 19 direct and enhanced operations. Its ten inventory operations are `list_documents`, `list_procedures`, `list_strings`, `list_names`, `list_segments`, `address_name`, `procedure_address`, `resolve_containing_procedure`, `search_procedures`, and `search_strings`. It also admits `procedure_info`, `procedure_pseudo_code`, `procedure_assembly`, `read_function_instructions`, `procedure_callers`, `procedure_callees`, `procedure_references`, `xrefs`, and `analyze_function`. `read_function_instructions` is the offset-paginated fast path for raw instruction windows: it does not invoke the decompiler or whole-program name/string inventories, and is also exposed as `rea instructions`. These capabilities enable the shared Swift/Objective-C inventory workflows, `binary_overview`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, `trace_feature`, and complete function dossiers. Default-space addresses are lowercase `0x` hexadecimal. Other spaces, including `EXTERNAL`, use `<percent-encoded-space>:0x<hex>`. Symbol results identify primary, dynamic, external, type, and source facts; procedures distinguish external functions and thunks; strings identify charset, missing-terminator state, byte length, and value truncation; memory-block ends are exclusive and permissions come directly from Ghidra.
|
||||
|
||||
The bridge serves operations only after auto-analysis completes. Each Program owns one persistent `DecompInterface`; a bounded 32-request FIFO serializes Ghidra API access, and every decompile has a 30-second native deadline. Reference results preserve Ghidra's call/jump/data/read/write/indirect/computed/external facts, while unresolved targetless flows remain explicitly unknown. Synthetic entry-point references without actionable memory sources are omitted. Pseudocode and assembly are provider-specific observations, not original source or Hopper-equivalent text. An analysis timeout, scan or inventory safety limit, request timeout, or oversized response fails explicitly instead of returning a partial result labeled complete.
|
||||
|
||||
`npm run verify:ghidra` compiles source-owned x86-64 debug and stripped ELF, AArch64 ELF, x86-64 PE, and x86-64 Mach-O fixtures. Against real Ghidra 12.1.2 it validates every admitted operation, direct and indirect calls, imports/exports/thunks, typed references, strings/xrefs, multi-block CFG, cancellation, deadlines, concurrency, malformed inputs, and complete process/project cleanup. Set `REA_CC`, `REA_CLANG`, or `REA_LLD_LINK` only when the corresponding compiler command is not on `PATH`.
|
||||
|
||||
`npm run verify:ghidra:windows` uses a deterministic source-owned native x86-64 PE application and requires all 18 operations, target/snapshot/import digest linkage, authenticated loopback transport, and cleanup on a controlled Windows x64 Ghidra 12.1.2 runner. This proof does not establish Job Object ownership, private DACLs, or reparse-point-safe authority.
|
||||
`npm run verify:ghidra:windows` uses a deterministic source-owned native x86-64 PE application and requires all 19 operations, target/snapshot/import digest linkage, authenticated loopback transport, and cleanup on a controlled Windows x64 Ghidra 12.1.2 runner. This proof does not establish Job Object ownership, private DACLs, or reparse-point-safe authority.
|
||||
|
||||
To remove only REA-owned MCP registrations and the managed skill:
|
||||
|
||||
@@ -371,17 +371,18 @@ REA handles the app analysis in steps 1–5. The agent performs step 6 with its
|
||||
|
||||
## Tool catalog 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 |
|
||||
| Managed PE/CLI | 8 | PE/CLI identity, metadata members, CIL hashes, P/Invoke/native-boundary declarations and verification, application-graph projection, decompiler reconstruction import, token remapping, runtime-correlation plans, and version comparison |
|
||||
| Browser observation | 8 | exact-origin CDP capture, bundle and source-map analysis, WebMCP discovery, session timelines, capture diff, and visual evidence |
|
||||
| Electron analysis | 4 | passive root-confined observation, bounded static application mapping, and evidence-backed static/runtime reconciliation |
|
||||
| Application workflows | 9 | bounded cross-layer traces, unique-only version matching, static export return-shape comparison, approved Linux-isolated extracted-module replay, managed-runtime characterization, and reconstruction coverage closure |
|
||||
| Workspace and observation | 19 | target lifecycle, Evidence v2 bundles, direct finite replay-machine evaluation, process/artifact/function comparison, evidence-linked residual-unknown lifecycle |
|
||||
| Tool family | Count | Examples |
|
||||
| ------------------------- | ----: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Native inspection | 36 | procedures, pseudocode, assembly, strings, names, segments, callers, callees, xrefs, annotations, bounded byte reads, file-offset translation |
|
||||
| Investigation workflows | 12 | `binary_overview`, `analyze_function`, `batch_decompile`, `trace_feature`, exact string-to-code lookup, bounded call paths, 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 | 3 | bounded provider-neutral inspection, deterministic directory/ZIP/APK/IPA/MSIX/AppX/ASAR inventory, and explicitly selected extraction into an absent owned tree |
|
||||
| Managed PE/CLI | 8 | PE/CLI identity, metadata members, CIL hashes, P/Invoke/native-boundary declarations and verification, application-graph projection, decompiler reconstruction import, token remapping, runtime-correlation plans, and version comparison |
|
||||
| Browser observation | 9 | exact-origin passive CDP capture, bundle and source-map analysis, WebMCP discovery, session timelines, capture diff, visual evidence, and bounded Playwright scenarios |
|
||||
| Electron analysis | 4 | passive root-confined observation, bounded static application mapping, and evidence-backed static/runtime reconciliation |
|
||||
| JavaScript runtime | 2 | approved attach-only Node/Electron Inspector target discovery plus bounded script and execution-context observation without evaluation or instrumentation |
|
||||
| Application workflows | 12 | bounded cross-layer traces, unique-only version matching, historical-source to bundle mapping, static export return-shape comparison, approved Linux-isolated extracted-module replay, managed-runtime characterization, reconstruction coverage closure, deterministic obligation ledgers, and end-to-end readiness conformance |
|
||||
| Workspace and observation | 19 | target lifecycle, Evidence v2 bundles, direct finite replay-machine evaluation, 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.
|
||||
|
||||
@@ -393,10 +394,11 @@ REA is already useful for native application, browser, and Electron investigatio
|
||||
- Discover deep-analysis candidates without starting them, choose deterministically, and retain one immutable provider/profile binding until an explicit switch or close; provider failures never trigger transparent fallback.
|
||||
- Attach to a user-owned Chrome-family browser over a configured loopback CDP endpoint; capture exact-origin web structure, safe metadata, approved value-free payload shapes, bundle/source-map evidence, WebMCP declarations, user-action timelines, capture diffs, and explicitly approved screenshots without navigation or JavaScript evaluation.
|
||||
- Inspect Electron `file://` renderer pages through a separate canonical-root permission boundary without invoking Electron APIs; script contents remain separately approved and byte bounded.
|
||||
- Attach to one exact approved Node or Electron V8 Inspector target and retain bounded `scriptParsed` plus execution-context lifecycle metadata without evaluation, breakpoints, resume, source reads, or instrumentation. require/import edges, EventEmitter activity, Electron IPC, PID identity, and role identity remain unknown. See [passive Node and Electron runtime observation](docs/javascript-runtime-observation.md).
|
||||
- Validate and canonically serialize a provider-neutral [JavaScript Application Graph v1](docs/javascript-application-graph.md) spanning packages, ASAR entries, Electron roles, JavaScript/source-map entities, browser/runtime instances, IPC, endpoints, storage, and native add-ons. This shipped domain contract performs no extraction or I/O by itself.
|
||||
- Reconstruct bounded static package, entrypoint, Webpack/Rspack module, import, worker, endpoint, storage, source-map, BrowserWindow, preload, contextBridge, IPC, utility-process, and native-add-on structure from an approved local directory or ASAR through `analyze_javascript_application` or `rea analyze-javascript-application`. The AST-only [application service](docs/javascript-artifact-reconstruction.md) never executes bootstrap code, pairs only unique exact literal IPC channels, and reports dynamic or ambiguous channels as unresolved.
|
||||
- Reconcile that static graph with existing passive web or Electron Evidence through `reconcile_javascript_runtime` or `rea reconcile-javascript-runtime`. Exact captured bytes outrank caller-declared file/URL mappings; target, frame, script, worker, cache, and asset ambiguity stays explicit, source-map authority stays separate, and a module resident in an observed bundle is never reported as executed. See [JavaScript static/runtime reconciliation](docs/javascript-runtime-reconciliation.md).
|
||||
- Trace a literal route, string, API, IPC channel, module, or native export through authenticated application Evidence with explicit traversal bounds, then hand exact native artifact digests and requested exports to retained Ghidra or Hopper Evidence without automatic provider switching. Compare application versions using unique-only digest, source-map, structural, and semantic tiers; compare one exact JavaScript export's static return shapes through unique literal discriminants and bounded JSON Pointer changes. Duplicate, dynamic, incomplete, and truncated facts stay unknown. See [cross-layer JavaScript application workflows](docs/javascript-application-workflows.md).
|
||||
- Trace a literal route, string, API, IPC channel, module, or native export through authenticated application Evidence with explicit traversal bounds, then hand exact native artifact digests and requested exports to retained Ghidra or Hopper Evidence without automatic provider switching. Compare application versions using unique-only digest, source-map, structural, and semantic tiers; map a committed historical source inventory to bundle nodes with explicit digest and path scores; compare one exact JavaScript export's static return shapes through unique literal discriminants and bounded JSON Pointer changes. Duplicate, dynamic, incomplete, ambiguous, and truncated facts stay unknown. See [cross-layer JavaScript application workflows](docs/javascript-application-workflows.md).
|
||||
- Classify PE/CLI managed artifacts with `inspect_managed_artifact` / `rea inspect-managed-artifact`, inspect bounded metadata members, signatures, raw CIL hashes, limited decoded-instruction-tuple v1 hashes, separately reported exception regions, call edges, and field-access anchors with `inspect_managed_members` / `rea inspect-managed-members`, inventory declared ModuleRef/ImplMap/PInvoke and non-IL method boundary indicators with `inspect_managed_native_boundaries` / `rea inspect-managed-native-boundaries`, then compare two authenticated member observations with `compare_managed_members` / `rea compare-managed-members`. `verify_managed_native_boundaries` / `rea verify-managed-native-boundaries` checks managed P/Invoke declarations against authenticated native export or function Evidence while keeping verified, inferred, contradicted, and unresolved states distinct. The comparison treats build-local tokens as build-local and uses unique decoded-CIL/signature and structural method-shape tiers, never names alone; the v1 tuple hash does not itself resolve tokens or fully commit control flow. `project_managed_application_graph` / `rea project-managed-application-graph` projects authenticated managed artifact/member/native-boundary Evidence into the existing application graph for cross-layer feature tracing. `import_managed_reconstruction` / `rea import-managed-reconstruction` admits user-supplied decompiler C#/IL/pseudocode as analyst inference only after exact artifact SHA-256, MVID, signature, and the shipped v1 decoded-IL commitment match. Separately, `plan_managed_runtime_correlation` / `rea plan-managed-runtime-correlation` can admit a default-disabled, permission-gated runtime-correlation plan locked to the same build evidence. These paths never load the assembly, resolve CLR dependencies, execute target code, run a decompiler, or translate managed tokens into native addresses; full normalized-CIL v2 semantics, native-body bridge mapping, and an actual runtime executor remain future managed-code contracts.
|
||||
- Configure `REA_ILSPY_CMD_PATH=/absolute/path/to/ilspycmd` only when you want
|
||||
doctor and `verify:managed` to inspect a bring-your-own ILSpy command as a
|
||||
@@ -438,6 +440,55 @@ rea inspect-web-page http://127.0.0.1:9222 TARGET_ID --approved --json
|
||||
|
||||
All eight browser tools expose the same Evidence v2 contracts over CLI and MCP. Inspection is passive: REA does not evaluate page JavaScript, navigate, click, close the page, or close the browser. Query values, credentials, cookies, authorization headers, storage values, and raw JSON or WebSocket values are never retained. Separately approved captures can retain bounded redacted console primitives, value-free JSON/WebSocket shapes, script sources, accessibility text, or screenshot pixels. Existing activity before attach is explicitly unavailable. See [Website observation with CDP](docs/browser-observation.md) for browser startup, schemas, limits, and the threat model.
|
||||
|
||||
### Controlled browser scenarios
|
||||
|
||||
`capture_browser_scenario` is a separate, explicitly mutating browser boundary.
|
||||
It runs only the fixed, versioned scenario vocabulary through Playwright and
|
||||
returns step-indexed Evidence for screenshots, DOM, accessibility, URL/history,
|
||||
storage, console/errors, network, WebSockets, frames, workers, popups, and
|
||||
cancelled downloads. Missing or truncated sections can never support equality
|
||||
claims.
|
||||
|
||||
```bash
|
||||
export REA_BROWSER_SCENARIO_ENABLED=true
|
||||
export REA_BROWSER_SCENARIO_EXECUTABLE_ROOTS_JSON='["/usr/bin"]'
|
||||
export REA_BROWSER_SCENARIO_CDP_ENDPOINTS_JSON='["http://127.0.0.1:9222"]'
|
||||
export REA_BROWSER_SCENARIO_ALLOWED_ORIGINS_JSON='["http://127.0.0.1:3000"]'
|
||||
export REA_BROWSER_SCENARIO_ALLOWED_ENV_JSON='["REA_TEST_PASSWORD"]'
|
||||
|
||||
rea capture-browser-scenario ./scenario.json --json
|
||||
```
|
||||
|
||||
Launch mode owns a temporary browser profile and removes it after terminating
|
||||
the launched browser. Connect mode accepts one exact loopback CDP target and
|
||||
disconnects without closing the external browser. Automation has no default
|
||||
grant: use the shared project/session policy, or set
|
||||
`REA_BROWSER_SCENARIO_AUTO_GRANT=true` only for a trusted unattended
|
||||
environment. Scenario JSON contains secret references and environment-variable
|
||||
names, never secret values. See the
|
||||
[browser scenario contract](docs/browser-scenario-contract.md).
|
||||
|
||||
### Node and Electron V8 Inspector observation
|
||||
|
||||
Attach-only JavaScript runtime observation is separately disabled by default:
|
||||
|
||||
```bash
|
||||
export REA_V8_INSPECTOR_OBSERVE_ENABLED=true
|
||||
export REA_V8_INSPECTOR_ENDPOINTS_JSON='["http://127.0.0.1:9229"]'
|
||||
export REA_V8_INSPECTOR_FILE_ROOTS_JSON='["/absolute/path/to/app"]'
|
||||
export REA_V8_INSPECTOR_ALLOWED_ORIGINS_JSON='[]'
|
||||
|
||||
rea list-javascript-runtime-targets http://127.0.0.1:9229 --approved --json
|
||||
rea observe-javascript-runtime http://127.0.0.1:9229 TARGET_ID \
|
||||
--runtime-kind node --approved --json
|
||||
```
|
||||
|
||||
REA sends only `Runtime.enable` and `Debugger.enable`. It retains bounded,
|
||||
scope-authorized script locations and execution-context lifecycle events;
|
||||
require/import edges, EventEmitter activity, Electron IPC, PID identity, and
|
||||
Electron role identity stay explicit unknowns. See
|
||||
[passive Node and Electron runtime observation](docs/javascript-runtime-observation.md).
|
||||
|
||||
Exact package, tool-family, provider, setup-client, public schema-version, and CLI facts are generated from source in [`docs/product-catalog.json`](docs/product-catalog.json). PR CI verifies this catalog, narrative documentation, generated schemas, and a clean TypeDoc render.
|
||||
|
||||
## Roadmap
|
||||
@@ -479,7 +530,7 @@ Setup detects Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf,
|
||||
"mcpServers": {
|
||||
"rea": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "rea-agents@2.3.0", "mcp"]
|
||||
"args": ["-y", "rea-agents@2.5.0", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -592,10 +643,17 @@ and retains one provider-attributed, token-verified observation per started
|
||||
provider. Each observation carries `observed_at` and remains `unavailable` or
|
||||
`verified`; a verified empty descendant list is distinct from both. Snapshots
|
||||
describe bounded observations, not current live state or historical absence.
|
||||
For providers with serial work, `analysis_activity` distinguishes `idle`,
|
||||
`busy`, and `timed_out_busy`, and reports the active operation, elapsed time,
|
||||
caller state, timeout, and queued-request count. A caller timeout therefore
|
||||
does not falsely imply that Hopper's Python thread is available. `close_binary`
|
||||
clears the REA session but returns `cleanup_incomplete` when authenticated
|
||||
document shutdown, owned process cleanup, or private runtime removal cannot be
|
||||
verified.
|
||||
Reopening same target without a selector keeps its binding; runtime failure
|
||||
never selects another provider silently.
|
||||
Ghidra can appear as an available, target-compatible candidate after doctor
|
||||
validates its exact installation. Its capability list contains the 18 admitted
|
||||
validates its exact installation. Its capability list contains the 19 admitted
|
||||
read-only inventory and function-analysis operations; selecting it still does
|
||||
not make Hopper-only GUI or mutation operations available and never triggers a
|
||||
silent fallback.
|
||||
@@ -627,7 +685,11 @@ REA starts Hopper when needed; Hopper does not need to be running first. Hopper'
|
||||
|
||||
REA derives explicit format and architecture arguments to prevent common FAT and ARM selection dialogs. Other Hopper or macOS dialogs may still require a person. REA reports timeouts and remediation through CLI or MCP results instead of attempting to answer UI prompts.
|
||||
|
||||
Closing a REA session shuts down its bridge and removes its private socket directory. It does not quit a Hopper application the user may be using.
|
||||
Hopper bridge calls pass through a bounded serial FIFO because Hopper's Python API runs on one dedicated thread. Queue wait counts against the caller deadline; a timeout settles the caller but does not release the wire slot until Hopper actually replies. `binary_session.analysis_activity` exposes that detached work and MCP progress reports elapsed time. Successful decompilation text is cached per document and procedure, shared by pseudocode and dossier requests, and invalidated after rename or comment mutation.
|
||||
|
||||
Hopper's synchronous public Python calls cannot stream a partial response after the caller deadline. Use `read_function_instructions` or `rea instructions` when raw instruction orientation is enough: this path does not decompile or scan whole-program name/string inventories. If a dossier has already timed out, inspect `analysis_activity`; do not assume a raw request can enter the serial bridge until that detached Hopper call actually returns.
|
||||
|
||||
Closing a REA session shuts down its bridge and removes its private socket directory. It does not quit a Hopper application the user may be using. If shutdown or cleanup cannot be verified, `close_binary` returns `cleanup_incomplete` with the affected local resources.
|
||||
|
||||
## Advanced process-capture setup
|
||||
|
||||
|
||||
+13
-12
@@ -159,17 +159,18 @@ rea uninstall --purge-data
|
||||
|
||||
## منصة أدوات التحقيق
|
||||
|
||||
| عائلة الأدوات | العدد | أمثلة |
|
||||
| --------------------- | ----: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| فحص الملفات التنفيذية | 33 | الدوال، والشيفرة شبه المصدرية، والتعليمات، والسلاسل، والأسماء، والمراجع، والتعليقات |
|
||||
| التحليل المركب | 10 | النظرة العامة، وفك الترجمة الدفعي، ومخططات الاستدعاء، والمراجع، واكتشاف الأنواع |
|
||||
| أدوات macOS الأصلية | 5 | بيانات Mach-O والتوقيعات وملفات plist والمعماريات وفك رموز Swift دون تشغيل Hopper |
|
||||
| رسم القطع الأثرية | 2 | جرد حتمي للدلائل وZIP/APK/IPA وASAR واستخراج معاملاتي محدد صراحة |
|
||||
| Managed PE/CLI | 8 | هوية PE/CLI، أعضاء البيانات الوصفية، بصمات CIL، تصريحات P/Invoke/الحدود الأصلية والتحقق منها، إسقاط رسم التطبيق، استيراد إعادة البناء من فك الترجمة، إعادة ربط الرموز، خطط ترابط وقت التشغيل ومقارنة الإصدارات |
|
||||
| مراقبة المتصفح | 8 | التقاط CDP محدود الأصل وتحليل الحزم وخرائط المصدر واكتشاف WebMCP والفروق والأدلة المرئية |
|
||||
| تحليل Electron | 4 | مراقبة سلبية ضمن جذور canonical، ورسم ثابت ومحدود للتطبيق، ومطابقة ثابتة/وقت تشغيل قائمة على Evidence |
|
||||
| سير عمل التطبيقات | 9 | تتبع محدود ومقارنة إصدارات فريدة ومقارنة ثابتة لأشكال قيم إرجاع الصادرات وإعادة تشغيل وحدات معزولة ومعتمدة على Linux وتوصيف وقت التشغيل المُدار وإغلاق تغطية إعادة البناء |
|
||||
| جلسة الملف التنفيذي | 19 | فتح الأهداف وحزم الأدلة وتشغيل آلة إعادة محدودة ومقارنة العمليات والقطع والدوال وسجل المجهولات المتبقية |
|
||||
| عائلة الأدوات | العدد | أمثلة |
|
||||
| --------------------- | ----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| فحص الملفات التنفيذية | 36 | الدوال، والشيفرة شبه المصدرية، والتعليمات، والسلاسل، والأسماء، والمراجع، والتعليقات |
|
||||
| التحليل المركب | 12 | النظرة العامة، وفك الترجمة الدفعي، ومخططات الاستدعاء، والمراجع، واكتشاف الأنواع |
|
||||
| أدوات macOS الأصلية | 5 | بيانات Mach-O والتوقيعات وملفات plist والمعماريات وفك رموز Swift دون تشغيل Hopper |
|
||||
| رسم القطع الأثرية | 3 | جرد حتمي للدلائل وZIP/APK/IPA وASAR واستخراج معاملاتي محدد صراحة |
|
||||
| Managed PE/CLI | 8 | هوية PE/CLI، أعضاء البيانات الوصفية، بصمات CIL، تصريحات P/Invoke/الحدود الأصلية والتحقق منها، إسقاط رسم التطبيق، استيراد إعادة البناء من فك الترجمة، إعادة ربط الرموز، خطط ترابط وقت التشغيل ومقارنة الإصدارات |
|
||||
| مراقبة المتصفح | 9 | التقاط CDP محدود الأصل وتحليل الحزم وخرائط المصدر واكتشاف WebMCP والفروق والأدلة المرئية |
|
||||
| تحليل Electron | 4 | مراقبة سلبية ضمن جذور canonical، ورسم ثابت ومحدود للتطبيق، ومطابقة ثابتة/وقت تشغيل قائمة على Evidence |
|
||||
| وقت تشغيل JavaScript | 2 | اكتشاف أهداف Node/Electron Inspector المعتمدة للاتصال فقط، ومراقبة محدودة للبرامج النصية وسياقات التنفيذ دون تقييم أو تجهيز |
|
||||
| سير عمل التطبيقات | 12 | تتبع محدود ومقارنة إصدارات فريدة ومقارنة ثابتة لأشكال قيم إرجاع الصادرات وإعادة تشغيل وحدات معزولة ومعتمدة على Linux وتوصيف وقت التشغيل المُدار وإغلاق تغطية إعادة البناء ودفاتر التزامات حتمية والتحقق الشامل من جاهزية إعادة البناء |
|
||||
| جلسة الملف التنفيذي | 19 | فتح الأهداف وحزم الأدلة وتشغيل آلة إعادة محدودة ومقارنة العمليات والقطع والدوال وسجل المجهولات المتبقية |
|
||||
|
||||
## استخدام REA مع وكلاء برمجة آخرين
|
||||
|
||||
@@ -182,7 +183,7 @@ rea uninstall --purge-data
|
||||
"mcpServers": {
|
||||
"rea": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "rea-agents@2.3.0", "mcp"]
|
||||
"args": ["-y", "rea-agents@2.5.0", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-12
@@ -149,17 +149,18 @@ REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェ
|
||||
|
||||
## 調査ツールカタログ
|
||||
|
||||
| ツール群 | 数 | 例 |
|
||||
| ---------------------- | --: | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| バイナリ調査 | 33 | プロシージャ、疑似コード、アセンブリ、文字列、名前、セグメント、callers、callees、xrefs、注釈 |
|
||||
| 合成解析 | 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 の決定的一覧と明示選択されたトランザクション抽出 |
|
||||
| Managed PE/CLI | 8 | PE/CLI 識別、メタデータメンバー、CIL ハッシュ、P/Invoke/ネイティブ境界宣言と検証、アプリケーショングラフ投影、逆コンパイル再構成インポート、構造的 token 再マッピング、ランタイム相関計画、バージョン比較 |
|
||||
| ブラウザ観察 | 8 | origin 限定 CDP 取得、bundle/source map 解析、WebMCP 検出、セッション、capture diff、視覚証拠 |
|
||||
| Electron 解析 | 4 | canonical ファイルルート内の受動観察、有界な静的アプリマッピング、Evidence に基づく静的/実行時の照合 |
|
||||
| アプリケーション | 9 | 有界なクロスレイヤー追跡、一意限定のバージョン比較、静的なエクスポート戻り値形状比較、承認済み Linux 隔離 extracted-module replay、マネージドランタイム特性評価、再構築カバレッジ閉包 |
|
||||
| バイナリセッション | 19 | `open_binary`、`binary_session`、証拠バンドル、有限リプレイ状態機械、プロセス/成果物/関数比較、残余未知レジストリ |
|
||||
| ツール群 | 数 | 例 |
|
||||
| ---------------------- | --: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| バイナリ調査 | 36 | プロシージャ、疑似コード、アセンブリ、文字列、名前、セグメント、callers、callees、xrefs、注釈 |
|
||||
| 合成解析 | 12 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift / ObjC 検出 |
|
||||
| macOS ネイティブ | 5 | Mach-O メタデータ、署名、plist、アーキテクチャ、Swift デマングル。Hopper 起動不要 |
|
||||
| アーティファクトグラフ | 3 | ディレクトリ、ZIP/APK/IPA、ASAR の決定的一覧と明示選択されたトランザクション抽出 |
|
||||
| Managed PE/CLI | 8 | PE/CLI 識別、メタデータメンバー、CIL ハッシュ、P/Invoke/ネイティブ境界宣言と検証、アプリケーショングラフ投影、逆コンパイル再構成インポート、構造的 token 再マッピング、ランタイム相関計画、バージョン比較 |
|
||||
| ブラウザ観察 | 9 | origin 限定 CDP 取得、bundle/source map 解析、WebMCP 検出、セッション、capture diff、視覚証拠 |
|
||||
| Electron 解析 | 4 | canonical ファイルルート内の受動観察、有界な静的アプリマッピング、Evidence に基づく静的/実行時の照合 |
|
||||
| JavaScript ランタイム | 2 | 承認済みのアタッチ専用 Node/Electron Inspector ターゲット検出と、評価や計装を行わない有界なスクリプト/実行コンテキスト観察 |
|
||||
| アプリケーション | 12 | 有界なクロスレイヤー追跡、一意限定のバージョン比較、静的なエクスポート戻り値形状比較、承認済み Linux 隔離 extracted-module replay、マネージドランタイム特性評価、再構築カバレッジ閉包、決定論的な義務台帳、エンドツーエンドの準備適合性検証 |
|
||||
| バイナリセッション | 19 | `open_binary`、`binary_session`、証拠バンドル、有限リプレイ状態機械、プロセス/成果物/関数比較、残余未知レジストリ |
|
||||
|
||||
## 他のコーディングエージェントで使う
|
||||
|
||||
@@ -172,7 +173,7 @@ Setup は Claude Code、Claude Desktop、Codex、Cursor、Gemini CLI、Windsurf
|
||||
"mcpServers": {
|
||||
"rea": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "rea-agents@2.3.0", "mcp"]
|
||||
"args": ["-y", "rea-agents@2.5.0", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-12
@@ -149,17 +149,18 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이
|
||||
|
||||
## 조사 도구 카탈로그
|
||||
|
||||
| 도구 그룹 | 수 | 예시 |
|
||||
| ------------------- | --: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 바이너리 검사 | 33 | 프로시저, 의사 코드, 어셈블리, 문자열, 이름, 세그먼트, callers, callees, xrefs, 주석 |
|
||||
| 합성 분석 | 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 결정적 인벤토리와 명시적으로 선택한 트랜잭션 추출 |
|
||||
| Managed PE/CLI | 8 | PE/CLI ID, 메타데이터 멤버, CIL 해시, P/Invoke/네이티브 경계 선언 및 검증, 애플리케이션 그래프 투영, 디컴파일 재구성 가져오기, 구조적 token 재매핑, 런타임 상관 계획, 버전 비교 |
|
||||
| 브라우저 관찰 | 8 | origin 제한 CDP 캡처, bundle/source map 분석, WebMCP 검색, 세션 타임라인, capture diff 및 시각 증거 |
|
||||
| Electron 분석 | 4 | canonical 파일 루트 내 수동 관찰, 제한된 정적 앱 매핑, Evidence 기반 정적/런타임 조정 |
|
||||
| 애플리케이션 | 9 | 제한된 교차 계층 추적, 고유 일치 버전 비교, 정적 export 반환 형태 비교, 승인된 Linux 격리 extracted-module replay, 관리형 런타임 특성화, 재구성 커버리지 폐쇄 |
|
||||
| 바이너리 세션 | 19 | `open_binary`, `binary_session`, 증거 번들, 유한 replay 상태 머신, 프로세스·아티팩트·함수 비교, 잔여 미지수 레지스트리 |
|
||||
| 도구 그룹 | 수 | 예시 |
|
||||
| ------------------- | --: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 바이너리 검사 | 36 | 프로시저, 의사 코드, 어셈블리, 문자열, 이름, 세그먼트, callers, callees, xrefs, 주석 |
|
||||
| 합성 분석 | 12 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift 및 ObjC 탐색 |
|
||||
| macOS 네이티브 도구 | 5 | Mach-O 메타데이터, 코드 서명, plist, 아키텍처, Swift 디맹글링. Hopper 실행 불필요 |
|
||||
| 아티팩트 그래프 | 3 | 디렉터리, ZIP/APK/IPA, ASAR 결정적 인벤토리와 명시적으로 선택한 트랜잭션 추출 |
|
||||
| Managed PE/CLI | 8 | PE/CLI ID, 메타데이터 멤버, CIL 해시, P/Invoke/네이티브 경계 선언 및 검증, 애플리케이션 그래프 투영, 디컴파일 재구성 가져오기, 구조적 token 재매핑, 런타임 상관 계획, 버전 비교 |
|
||||
| 브라우저 관찰 | 9 | origin 제한 CDP 캡처, bundle/source map 분석, WebMCP 검색, 세션 타임라인, capture diff 및 시각 증거 |
|
||||
| Electron 분석 | 4 | canonical 파일 루트 내 수동 관찰, 제한된 정적 앱 매핑, Evidence 기반 정적/런타임 조정 |
|
||||
| JavaScript 런타임 | 2 | 승인된 연결 전용 Node/Electron Inspector 대상 탐색과 평가·계측 없는 제한된 스크립트 및 실행 컨텍스트 관찰 |
|
||||
| 애플리케이션 | 12 | 제한된 교차 계층 추적, 고유 일치 버전 비교, 정적 export 반환 형태 비교, 승인된 Linux 격리 extracted-module replay, 관리형 런타임 특성화, 재구성 커버리지 폐쇄, 결정론적 의무 원장, 엔드투엔드 준비 적합성 검증 |
|
||||
| 바이너리 세션 | 19 | `open_binary`, `binary_session`, 증거 번들, 유한 replay 상태 머신, 프로세스·아티팩트·함수 비교, 잔여 미지수 레지스트리 |
|
||||
|
||||
## 다른 코딩 에이전트에서 사용하기
|
||||
|
||||
@@ -172,7 +173,7 @@ Setup은 Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf, Devin
|
||||
"mcpServers": {
|
||||
"rea": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "rea-agents@2.3.0", "mcp"]
|
||||
"args": ["-y", "rea-agents@2.5.0", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-12
@@ -149,17 +149,18 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
|
||||
|
||||
## 调查工具目录
|
||||
|
||||
| 工具类别 | 数量 | 示例 |
|
||||
| -------------- | ---: | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 二进制检查 | 33 | 过程、伪代码、汇编、字符串、名称、段、调用者、被调用者、交叉引用、注释 |
|
||||
| 组合分析 | 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 清单,以及显式选择的事务式提取 |
|
||||
| Managed PE/CLI | 8 | PE/CLI 身份、元数据成员、CIL 哈希、P/Invoke/原生边界声明与验证、应用图谱投影、反编译重建导入、结构化 token 重映射、运行时关联计划与跨版本比较 |
|
||||
| 浏览器观察 | 8 | 精确来源 CDP 抓取、bundle/source map 分析、WebMCP 发现、会话时间线、capture diff 与视觉证据 |
|
||||
| Electron 分析 | 4 | 限定 canonical 文件根的被动观察、有界静态应用映射,以及基于 Evidence 的静态/运行时对账 |
|
||||
| 应用工作流 | 9 | 有界跨层追踪、唯一匹配跨版本图比较、静态导出返回形状比较、经批准的 Linux 隔离 extracted-module replay、托管运行时表征和重建覆盖闭合 |
|
||||
| 二进制会话 | 19 | `open_binary`、`binary_session`、证据包、有限重放状态机、进程、制品与函数比较、残余未知项注册表 |
|
||||
| 工具类别 | 数量 | 示例 |
|
||||
| ----------------- | ---: | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 二进制检查 | 36 | 过程、伪代码、汇编、字符串、名称、段、调用者、被调用者、交叉引用、注释 |
|
||||
| 组合分析 | 12 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`、Swift 与 ObjC 发现 |
|
||||
| macOS 原生工具 | 5 | Mach-O 元数据、代码签名、plist、架构与 Swift 符号还原,无需启动 Hopper |
|
||||
| 制品图谱 | 3 | 确定性目录、ZIP/APK/IPA 与 ASAR 清单,以及显式选择的事务式提取 |
|
||||
| Managed PE/CLI | 8 | PE/CLI 身份、元数据成员、CIL 哈希、P/Invoke/原生边界声明与验证、应用图谱投影、反编译重建导入、结构化 token 重映射、运行时关联计划与跨版本比较 |
|
||||
| 浏览器观察 | 9 | 精确来源 CDP 抓取、bundle/source map 分析、WebMCP 发现、会话时间线、capture diff 与视觉证据 |
|
||||
| Electron 分析 | 4 | 限定 canonical 文件根的被动观察、有界静态应用映射,以及基于 Evidence 的静态/运行时对账 |
|
||||
| JavaScript 运行时 | 2 | 经批准的只附加 Node/Electron Inspector 目标发现,以及不执行求值或插桩的有界脚本和执行上下文观察 |
|
||||
| 应用工作流 | 12 | 有界跨层追踪、唯一匹配跨版本图比较、静态导出返回形状比较、经批准的 Linux 隔离 extracted-module replay、托管运行时表征、重建覆盖闭合、确定性义务账本和端到端就绪性一致性验证 |
|
||||
| 二进制会话 | 19 | `open_binary`、`binary_session`、证据包、有限重放状态机、进程、制品与函数比较、残余未知项注册表 |
|
||||
|
||||
## 与其他编程智能体一起使用
|
||||
|
||||
@@ -172,7 +173,7 @@ Setup 会检测 Claude Code、Claude Desktop、Codex、Cursor、Gemini CLI、Win
|
||||
"mcpServers": {
|
||||
"rea": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "rea-agents@2.3.0", "mcp"]
|
||||
"args": ["-y", "rea-agents@2.5.0", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ import ghidra.program.model.symbol.Symbol;
|
||||
import ghidra.program.model.symbol.SymbolIterator;
|
||||
|
||||
public final class ReaGhidraBridge extends HeadlessScript {
|
||||
private static final int BRIDGE_VERSION = 4;
|
||||
private static final int BRIDGE_VERSION = 5;
|
||||
private static final int MAX_DESCRIPTOR_BYTES = 16 * 1024;
|
||||
private static final int MAX_REQUEST_CHARACTERS = 256 * 1024;
|
||||
private static final int MAX_RESPONSE_BYTES = 1024 * 1024;
|
||||
@@ -123,6 +123,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
"procedure_callers",
|
||||
"procedure_info",
|
||||
"procedure_pseudo_code",
|
||||
"read_function_instructions",
|
||||
"procedure_references",
|
||||
"xrefs"
|
||||
};
|
||||
@@ -311,6 +312,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
case "procedure_callers" -> procedureCalls(request.params, true);
|
||||
case "procedure_info" -> procedureInfo(request.params);
|
||||
case "procedure_pseudo_code" -> procedurePseudocode(request.params);
|
||||
case "read_function_instructions" -> readFunctionInstructions(request.params);
|
||||
case "procedure_references" -> procedureReferences(request.params);
|
||||
case "resolve_containing_procedure" -> containingProcedure(request.params);
|
||||
case "search_procedures" -> search(request.params, true);
|
||||
@@ -390,6 +392,51 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
return GSON.toJsonTree(renderAssembly(scan.instructions));
|
||||
}
|
||||
|
||||
private JsonObject readFunctionInstructions(JsonObject params) throws Exception {
|
||||
requireKeys(params, Set.of("document", "procedure", "offset", "limit"));
|
||||
requireDocument(params);
|
||||
Function function = resolveProcedure(requireString(params, "procedure"));
|
||||
int offset = requireBoundedInteger(params, "offset", 0, 100_000);
|
||||
int limit = requireBoundedInteger(params, "limit", 1, 500);
|
||||
InstructionScan scan = scanInstructions(function, offset + limit + 1);
|
||||
int start = Math.min(offset, scan.instructions.size());
|
||||
int end = Math.min(scan.instructions.size(), start + limit);
|
||||
JsonArray items = new JsonArray();
|
||||
for (String line : renderAssemblyLines(scan.instructions.subList(start, end))) {
|
||||
items.add(line);
|
||||
}
|
||||
boolean hasMore = scan.truncated || end < scan.instructions.size();
|
||||
JsonObject instructions = new JsonObject();
|
||||
instructions.add("items", items);
|
||||
if (scan.truncated) {
|
||||
instructions.add("total", JsonNull.INSTANCE);
|
||||
}
|
||||
else {
|
||||
instructions.addProperty("total", scan.instructions.size());
|
||||
}
|
||||
instructions.addProperty("returned", items.size());
|
||||
instructions.addProperty("truncated", hasMore);
|
||||
if (hasMore) {
|
||||
instructions.addProperty("next_offset", end);
|
||||
}
|
||||
else {
|
||||
instructions.add("next_offset", JsonNull.INSTANCE);
|
||||
}
|
||||
|
||||
JsonArray limitations = new JsonArray();
|
||||
limitations.add("Instruction text and ordering are Ghidra-specific representations.");
|
||||
limitations.add(
|
||||
"The fast path does not invoke the decompiler or scan whole-program names and strings."
|
||||
);
|
||||
JsonObject result = new JsonObject();
|
||||
result.add("procedure", procedureIdentity(function));
|
||||
result.add("instructions", instructions);
|
||||
result.addProperty("instructions_scanned", scan.instructions.size());
|
||||
result.addProperty("instruction_scan_truncated", scan.truncated);
|
||||
result.add("limitations", limitations);
|
||||
return result;
|
||||
}
|
||||
|
||||
private JsonArray procedureCalls(JsonObject params, boolean callers) throws Exception {
|
||||
requireKeys(params, Set.of("document", "procedure"));
|
||||
requireDocument(params);
|
||||
|
||||
+259
-24
@@ -11,19 +11,86 @@ import os
|
||||
import re
|
||||
import sre_parse
|
||||
import socket
|
||||
from typing import Any, Optional, Protocol, Sequence
|
||||
|
||||
MAX_LINE_BYTES = 10 * 1024 * 1024
|
||||
MAX_READ_BYTES = 4096
|
||||
BAD_ADDRESSES = (-1, 0xFFFFFFFFFFFFFFFF, None)
|
||||
_selected_document = None
|
||||
_search_inventory_cache = {}
|
||||
_pseudocode_cache = {}
|
||||
_hopper_api = None
|
||||
MAX_SEARCH_PATTERN_LENGTH = 256
|
||||
MAX_SEARCH_VALUE_LENGTH = 4096
|
||||
|
||||
|
||||
class HopperDocument(Protocol):
|
||||
"""Typed minimum used by the provider-neutral Hopper API facade."""
|
||||
|
||||
def getDocumentName(self) -> str: ...
|
||||
|
||||
def getExecutableFilePath(self) -> Optional[str]: ...
|
||||
|
||||
def getDatabaseFilePath(self) -> Optional[str]: ...
|
||||
|
||||
def backgroundProcessActive(self) -> bool: ...
|
||||
|
||||
|
||||
class HopperDocumentProvider(Protocol):
|
||||
"""Hopper's injected global document provider."""
|
||||
|
||||
@classmethod
|
||||
def getAllDocuments(cls) -> Sequence[HopperDocument]: ...
|
||||
|
||||
@classmethod
|
||||
def getCurrentDocument(cls) -> Optional[HopperDocument]: ...
|
||||
|
||||
|
||||
class HopperApiFacade:
|
||||
"""Small injected boundary around Hopper globals for standalone tests."""
|
||||
|
||||
def __init__(self, document_provider: HopperDocumentProvider):
|
||||
self._document_provider = document_provider
|
||||
|
||||
def documents(self) -> Sequence[HopperDocument]:
|
||||
return self._document_provider.getAllDocuments()
|
||||
|
||||
def current_document(self) -> Optional[HopperDocument]:
|
||||
return self._document_provider.getCurrentDocument()
|
||||
|
||||
def require_analysis_complete(
|
||||
self, document: HopperDocument, operation: str
|
||||
) -> None:
|
||||
if document.backgroundProcessActive():
|
||||
raise CapabilityUnavailableError(
|
||||
"%s requires completed Hopper background analysis" % operation
|
||||
)
|
||||
|
||||
|
||||
def _configure_hopper_api(document_provider):
|
||||
"""Inject Hopper's document provider or a standalone test facade."""
|
||||
global _hopper_api
|
||||
_hopper_api = HopperApiFacade(document_provider)
|
||||
|
||||
|
||||
def _api():
|
||||
"""Resolve Hopper globals lazily so importing this module needs no Hopper."""
|
||||
global _hopper_api
|
||||
if _hopper_api is not None:
|
||||
return _hopper_api
|
||||
document_provider = globals().get("Document")
|
||||
if document_provider is None:
|
||||
raise CapabilityUnavailableError(
|
||||
"Hopper Document API is unavailable outside the Hopper runtime"
|
||||
)
|
||||
_hopper_api = HopperApiFacade(document_provider)
|
||||
return _hopper_api
|
||||
|
||||
|
||||
def _session_document():
|
||||
"""Find only the document opened for this authenticated REA session."""
|
||||
target = os.path.realpath(REA_TARGET_PATH)
|
||||
for document in Document.getAllDocuments():
|
||||
for document in _api().documents():
|
||||
paths = (document.getExecutableFilePath(), document.getDatabaseFilePath())
|
||||
for path in paths:
|
||||
if path and os.path.realpath(path) == target:
|
||||
@@ -36,6 +103,31 @@ MAX_REGEX_CANDIDATE_LENGTH = 4096
|
||||
MAX_REGEX_SEARCH_WORK_UNITS = 1000000
|
||||
|
||||
|
||||
class CapabilityUnavailableError(Exception):
|
||||
"""The active Hopper build lacks one admitted public API operation."""
|
||||
|
||||
|
||||
EXHAUSTIVE_ANALYSIS_METHODS = frozenset(
|
||||
(
|
||||
"analyze_function",
|
||||
"list_names",
|
||||
"list_procedures",
|
||||
"list_strings",
|
||||
"procedure_address",
|
||||
"procedure_assembly",
|
||||
"procedure_callees",
|
||||
"procedure_callers",
|
||||
"procedure_info",
|
||||
"procedure_pseudo_code",
|
||||
"procedure_references",
|
||||
"read_function_instructions",
|
||||
"resolve_containing_procedure",
|
||||
"search_procedures",
|
||||
"search_strings",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _hex(value):
|
||||
return "0x%x" % value
|
||||
|
||||
@@ -54,7 +146,8 @@ def _json_safe(value):
|
||||
def _document(name=None):
|
||||
"""Resolve an explicit or session-selected document without changing Hopper UI."""
|
||||
global _selected_document
|
||||
documents = Document.getAllDocuments()
|
||||
api = _api()
|
||||
documents = api.documents()
|
||||
if name is not None:
|
||||
for candidate in documents:
|
||||
if candidate.getDocumentName() == name:
|
||||
@@ -64,7 +157,7 @@ def _document(name=None):
|
||||
for candidate in documents:
|
||||
if candidate.getDocumentName() == _selected_document:
|
||||
return candidate
|
||||
current = Document.getCurrentDocument()
|
||||
current = api.current_document()
|
||||
if current is None:
|
||||
raise ValueError("No Hopper document is loaded")
|
||||
return current
|
||||
@@ -225,12 +318,35 @@ def _invalidate_search_inventory(document):
|
||||
del _search_inventory_cache[key]
|
||||
|
||||
|
||||
def _invalidate_pseudocode(document):
|
||||
"""Discard cached decompilation after an analysis annotation changes."""
|
||||
document_id = id(document)
|
||||
for key in list(_pseudocode_cache):
|
||||
if key[0] == document_id:
|
||||
del _pseudocode_cache[key]
|
||||
|
||||
|
||||
def _pseudocode(document, procedure):
|
||||
"""Reuse one provider decompilation for repeated bounded projections."""
|
||||
key = (id(document), procedure.getEntryPoint())
|
||||
if key not in _pseudocode_cache:
|
||||
_pseudocode_cache[key] = procedure.decompile()
|
||||
return _pseudocode_cache[key]
|
||||
|
||||
|
||||
def _search_inventory(document, kind):
|
||||
"""Cache an immutable, address-sorted inventory for an unchanged document."""
|
||||
key = (id(document), kind)
|
||||
inventory = _search_inventory_cache.get(key)
|
||||
if inventory is None:
|
||||
values = _procedure_map(document) if kind == "procedure" else _strings(document)
|
||||
if kind == "procedure":
|
||||
values = _procedure_map(document)
|
||||
elif kind == "string":
|
||||
values = _strings(document)
|
||||
elif kind == "name":
|
||||
values = {_hex(address): value for address, value in _name_map(document).items()}
|
||||
else:
|
||||
raise ValueError("Unknown inventory kind")
|
||||
inventory = tuple(sorted(values.items(), key=lambda item: int(item[0], 16)))
|
||||
_search_inventory_cache[key] = inventory
|
||||
return inventory
|
||||
@@ -507,6 +623,22 @@ def _name_map(document):
|
||||
return result
|
||||
|
||||
|
||||
def _render_instruction(segment, address):
|
||||
instruction = segment.getInstructionAtAddress(address)
|
||||
if instruction is None:
|
||||
return None
|
||||
arguments = [
|
||||
instruction.getFormattedArgument(index)
|
||||
for index in range(instruction.getArgumentCount())
|
||||
]
|
||||
suffix = ", ".join(value for value in arguments if value is not None)
|
||||
return "%s: %s%s" % (
|
||||
_hex(address),
|
||||
instruction.getInstructionString(),
|
||||
(" " + suffix) if suffix else "",
|
||||
)
|
||||
|
||||
|
||||
def _assembly(procedure, limit=None):
|
||||
"""Render bounded assembly while guarding against malformed instruction cycles."""
|
||||
lines = []
|
||||
@@ -520,9 +652,9 @@ def _assembly(procedure, limit=None):
|
||||
instruction = segment.getInstructionAtAddress(address)
|
||||
if instruction is None:
|
||||
break
|
||||
arguments = [instruction.getFormattedArgument(index) for index in range(instruction.getArgumentCount())]
|
||||
suffix = ", ".join(value for value in arguments if value is not None)
|
||||
lines.append("%s: %s%s" % (_hex(address), instruction.getInstructionString(), (" " + suffix) if suffix else ""))
|
||||
line = _render_instruction(segment, address)
|
||||
if line is not None:
|
||||
lines.append(line)
|
||||
if limit is not None and len(lines) >= limit:
|
||||
return "\n".join(lines)
|
||||
length = instruction.getInstructionLength()
|
||||
@@ -532,6 +664,45 @@ def _assembly(procedure, limit=None):
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _read_function_instructions(document, params):
|
||||
"""Read one bounded raw-instruction window without invoking decompilation."""
|
||||
procedure = _procedure(document, params.get("procedure"))
|
||||
offset = _offset(params, "offset")
|
||||
limit = params.get("limit", 64)
|
||||
if offset > 100000:
|
||||
raise ValueError("offset must not exceed 100000")
|
||||
if not isinstance(limit, int) or isinstance(limit, bool) or limit < 1 or limit > 500:
|
||||
raise ValueError("limit must be an integer between 1 and 500")
|
||||
addresses, scan_truncated = _instruction_addresses(
|
||||
procedure, offset + limit + 1
|
||||
)
|
||||
start = min(offset, len(addresses))
|
||||
end = min(len(addresses), start + limit)
|
||||
segment = procedure.getSegment()
|
||||
items = []
|
||||
for address in addresses[start:end]:
|
||||
line = _render_instruction(segment, address)
|
||||
if line is not None:
|
||||
items.append(line)
|
||||
has_more = scan_truncated or end < len(addresses)
|
||||
return {
|
||||
"procedure": _procedure_identity(procedure),
|
||||
"instructions": {
|
||||
"items": items,
|
||||
"total": None if scan_truncated else len(addresses),
|
||||
"returned": len(items),
|
||||
"truncated": has_more,
|
||||
"next_offset": end if has_more else None,
|
||||
},
|
||||
"instructions_scanned": len(addresses),
|
||||
"instruction_scan_truncated": scan_truncated,
|
||||
"limitations": [
|
||||
"Instruction text and ordering are Hopper-specific representations.",
|
||||
"The fast path does not decompile the procedure or scan whole-program names and strings.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _analyze_function(document, params):
|
||||
"""Collect a bounded, single-pass function dossier for agent callers."""
|
||||
procedure = _procedure(document, params.get("procedure"))
|
||||
@@ -560,8 +731,8 @@ def _analyze_function(document, params):
|
||||
"end": _hex(block.getEndingAddress()),
|
||||
"successors": sorted(set(successors), key=lambda value: int(value, 16)),
|
||||
})
|
||||
pseudo = procedure.decompile() or ""
|
||||
assembly_lines = _assembly(procedure).splitlines() if params.get("include_assembly", False) else []
|
||||
pseudo = _pseudocode(document, procedure) or ""
|
||||
assembly_lines = _assembly(procedure, max_instructions).splitlines() if params.get("include_assembly", False) else []
|
||||
callers = sorted((_procedure_identity(item) for item in procedure.getAllCallerProcedures()), key=lambda item: int(item["address"], 16))
|
||||
callees = sorted((_procedure_identity(item) for item in procedure.getAllCalleeProcedures()), key=lambda item: int(item["address"], 16))
|
||||
comments = []
|
||||
@@ -595,8 +766,14 @@ def _analyze_function(document, params):
|
||||
incoming.append(item)
|
||||
if source in procedure_addresses:
|
||||
outgoing.append(item)
|
||||
string_map = {int(address, 16): value for address, value in _strings(document).items()}
|
||||
name_map = _name_map(document)
|
||||
string_map = {
|
||||
int(address, 16): value
|
||||
for address, value in _search_inventory(document, "string")
|
||||
}
|
||||
name_map = {
|
||||
int(address, 16): value
|
||||
for address, value in _search_inventory(document, "name")
|
||||
}
|
||||
referenced_strings = []
|
||||
referenced_names = []
|
||||
for edge in outgoing:
|
||||
@@ -615,7 +792,13 @@ def _analyze_function(document, params):
|
||||
return {
|
||||
"procedure": {"address": _hex(procedure.getEntryPoint()), "name": _procedure_name(procedure), "classification": None, "signature": procedure.signatureString(), "locals": _procedure_locals(procedure)},
|
||||
"pseudocode": {"text": pseudo_text, "total_chars": len(pseudo), "returned_chars": len(pseudo_text), "truncated": pseudo_next < len(pseudo), "next_offset": pseudo_next if pseudo_next < len(pseudo) else None},
|
||||
"assembly": _bounded(assembly_lines, assembly_offset, max_instructions),
|
||||
"assembly": _bounded(
|
||||
assembly_lines,
|
||||
assembly_offset,
|
||||
max_instructions,
|
||||
None,
|
||||
instruction_scan_truncated if params.get("include_assembly", False) else False,
|
||||
),
|
||||
"comments": collection("comments", comments, instruction_scan_truncated),
|
||||
"callers": collection("callers", callers), "callees": collection("callees", callees),
|
||||
"incoming_references": collection("incoming_references", incoming, instruction_scan_truncated),
|
||||
@@ -671,9 +854,54 @@ def _dispatch(method, params):
|
||||
return _selected_document
|
||||
|
||||
document = _document(params.get("document"))
|
||||
if method in EXHAUSTIVE_ANALYSIS_METHODS:
|
||||
_api().require_analysis_complete(document, method)
|
||||
|
||||
if method == "analyze_function":
|
||||
return _analyze_function(document, params)
|
||||
if method == "read_function_instructions":
|
||||
return _read_function_instructions(document, params)
|
||||
if method == "read_bytes":
|
||||
length = params.get("length", 256)
|
||||
if isinstance(length, bool) or not isinstance(length, int):
|
||||
raise ValueError("Byte-read length must be an integer")
|
||||
if length < 1 or length > MAX_READ_BYTES:
|
||||
raise ValueError("Byte-read length must be between 1 and 4096")
|
||||
reader = getattr(document, "readBytes", None)
|
||||
if not callable(reader):
|
||||
raise CapabilityUnavailableError(
|
||||
"Hopper's public Python API does not expose readBytes"
|
||||
)
|
||||
address = _address(document, params.get("address"))
|
||||
value = reader(address, length)
|
||||
if not isinstance(value, (bytes, bytearray)):
|
||||
raise CapabilityUnavailableError(
|
||||
"Hopper's public Python API returned an unsupported byte representation"
|
||||
)
|
||||
data = bytes(value)
|
||||
return {
|
||||
"address": _hex(address),
|
||||
"requested_bytes": length,
|
||||
"returned_bytes": len(data),
|
||||
"bytes_hex": data.hex(),
|
||||
"complete": len(data) == length,
|
||||
}
|
||||
if method == "address_to_file_offset":
|
||||
mapper = getattr(document, "getFileOffsetFromAddress", None)
|
||||
if not callable(mapper):
|
||||
raise CapabilityUnavailableError(
|
||||
"Hopper's public Python API does not expose getFileOffsetFromAddress"
|
||||
)
|
||||
address = _address(document, params.get("address"))
|
||||
offset = mapper(address)
|
||||
if (
|
||||
isinstance(offset, bool)
|
||||
or not isinstance(offset, int)
|
||||
or offset in BAD_ADDRESSES
|
||||
or offset < 0
|
||||
):
|
||||
raise ValueError("Address has no authoritative file-offset mapping")
|
||||
return {"address": _hex(address), "file_offset": offset}
|
||||
if method == "resolve_containing_procedure":
|
||||
address = _address(document, params.get("address"))
|
||||
procedure, reason = _containing_procedure(document, address)
|
||||
@@ -740,19 +968,16 @@ def _dispatch(method, params):
|
||||
})
|
||||
return result
|
||||
if method == "list_procedures":
|
||||
return _page(_procedure_map(document), params.get("offset", 0), params.get("limit", 100))
|
||||
return _page(dict(_search_inventory(document, "procedure")), params.get("offset", 0), params.get("limit", 100))
|
||||
if method == "list_strings":
|
||||
values = _strings(document)
|
||||
values = dict(_search_inventory(document, "string"))
|
||||
requested = params.get("address")
|
||||
if requested is not None:
|
||||
key = _hex(_address(document, requested))
|
||||
values = {key: values[key]} if key in values else {}
|
||||
return _page(values, params.get("offset", 0), params.get("limit", 100))
|
||||
if method == "list_names":
|
||||
result = {}
|
||||
for segment in document.getSegmentsList():
|
||||
for item in segment.getNamedAddresses():
|
||||
result[_hex(item)] = segment.getNameAtAddress(item)
|
||||
result = dict(_search_inventory(document, "name"))
|
||||
requested = params.get("address")
|
||||
if requested is not None:
|
||||
key = _hex(_address(document, requested))
|
||||
@@ -768,7 +993,7 @@ def _dispatch(method, params):
|
||||
if method == "procedure_assembly":
|
||||
return _assembly(procedure)
|
||||
if method == "procedure_pseudo_code":
|
||||
return procedure.decompile()
|
||||
return _pseudocode(document, procedure)
|
||||
if method == "procedure_callers":
|
||||
return sorted((_hex(item.getEntryPoint()) for item in procedure.getAllCallerProcedures()), key=lambda value: int(value, 16))
|
||||
if method == "procedure_callees":
|
||||
@@ -779,12 +1004,18 @@ def _dispatch(method, params):
|
||||
return {"name": _procedure_name(procedure), "entrypoint": _hex(procedure.getEntryPoint()), "basicblock_count": procedure.getBasicBlockCount(), "length": length, "signature": procedure.signatureString(), "locals": _procedure_locals(procedure), "classification": None}
|
||||
if method == "set_address_name":
|
||||
address = _address(document, params.get("address"))
|
||||
result = document.setNameAtAddress(address, params["name"])
|
||||
_invalidate_search_inventory(document)
|
||||
try:
|
||||
result = document.setNameAtAddress(address, params["name"])
|
||||
finally:
|
||||
_invalidate_search_inventory(document)
|
||||
_invalidate_pseudocode(document)
|
||||
return result
|
||||
if method == "set_addresses_names":
|
||||
result = {key: document.setNameAtAddress(_address(document, key), value) for key, value in params["names"].items()}
|
||||
_invalidate_search_inventory(document)
|
||||
try:
|
||||
result = {key: document.setNameAtAddress(_address(document, key), value) for key, value in params["names"].items()}
|
||||
finally:
|
||||
_invalidate_search_inventory(document)
|
||||
_invalidate_pseudocode(document)
|
||||
return result
|
||||
if method in ("set_comment", "set_inline_comment"):
|
||||
address = _address(document, params.get("address"))
|
||||
@@ -792,6 +1023,7 @@ def _dispatch(method, params):
|
||||
setter = segment.setCommentAtAddress if method == "set_comment" else segment.setInlineCommentAtAddress
|
||||
getter = segment.getCommentAtAddress if method == "set_comment" else segment.getInlineCommentAtAddress
|
||||
setter(address, params["comment"])
|
||||
_invalidate_pseudocode(document)
|
||||
return getter(address) == params["comment"]
|
||||
if method == "list_bookmarks":
|
||||
return [{"address": _hex(item), "name": document.getBookmarkName(item)} for item in document.getBookmarks()]
|
||||
@@ -838,6 +1070,8 @@ def _serve_connection(connection):
|
||||
|
||||
|
||||
def _diagnostic_type(error):
|
||||
if isinstance(error, CapabilityUnavailableError):
|
||||
return "capability_unavailable"
|
||||
if isinstance(error, PermissionError):
|
||||
return "authorization"
|
||||
if isinstance(error, (ValueError, TypeError, KeyError)):
|
||||
@@ -864,4 +1098,5 @@ def _run():
|
||||
|
||||
# Hopper's public objects are bound to its dedicated Python execution thread.
|
||||
# Keep dispatch on that thread; moving calls to an arbitrary worker can deadlock.
|
||||
_run()
|
||||
if __name__ == "__main__":
|
||||
_run()
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
target binding, analysis-profile commitment, and snapshot/Evidence migration
|
||||
are implemented. Ghidra discovery, target/profile resolution, doctor checks,
|
||||
the private headless-session foundation, ten read-only inventory capabilities,
|
||||
and eight function-analysis capabilities are implemented with real
|
||||
and nine function-analysis capabilities are implemented with real
|
||||
cross-format conformance.
|
||||
|
||||
## Context
|
||||
|
||||
@@ -5,11 +5,11 @@ REA can attach to a user-owned Chrome-family browser through the Chrome DevTools
|
||||
## Shipped surfaces
|
||||
|
||||
- `list_browser_targets` / `rea list-browser-targets` discovers page targets whose current URL matches an approved exact origin.
|
||||
- `inspect_web_page` / `rea inspect-web-page` captures bounded DOM structure, accessibility nodes, scripts, resources, safe response/DOM metadata, attach-window network and console metadata, WebSocket frame sizes, workers, quota, and optionally storage key names or script sources.
|
||||
- `inspect_web_page` / `rea inspect-web-page` captures bounded DOM structure, accessibility nodes, scripts, resources, safe response/DOM metadata, attach-window network and console metadata, WebSocket frame sizes, workers, quota, and optionally storage key names, redacted storage-content fingerprints, or script sources.
|
||||
- `analyze_web_bundle` / `rea analyze-web-bundle` parses approved script artifacts without execution and derives chunk edges, route and endpoint candidates, vendor fingerprints, static WebMCP declarations, and optionally approved source-map/original-source evidence.
|
||||
- `observe_web_session` / `rea observe-web-session` arms a bounded window for an external user action and records ordered reload, SPA navigation, redirect, failure, lifecycle, and target-termination metadata.
|
||||
- `discover_webmcp_tools` / `rea discover-webmcp-tools` uses the experimental CDP WebMCP domain to inventory page registrations. REA never exposes `WebMCP.invokeTool`.
|
||||
- `compare_web_captures` / `rea compare-web-captures` compares stable DOM, script, resource, network, metadata, and optional WebMCP identities without treating incomplete absence as equivalence.
|
||||
- `compare_web_captures` / `rea compare-web-captures` compares passive captures or exact-step-aligned browser scenarios. Scenario results commit reproducible literal normalization and expose alignment failures plus artifact-level action, screenshot, DOM, accessibility, URL, history, storage, and event differences. Missing or truncated evidence is never treated as equivalence.
|
||||
- `capture_web_screenshot` / `rea capture-web-screenshot` returns an explicitly approved, bounded, content-addressed visible-viewport PNG.
|
||||
- `compare_web_screenshots` / `rea compare-web-screenshots` performs bounded local PNG pixel comparison without OCR or external services.
|
||||
- Every surface has equivalent CLI and MCP contracts and returns Evidence v2 provenance.
|
||||
@@ -92,11 +92,11 @@ rea inspect-web-page http://127.0.0.1:9222 TARGET_ID \
|
||||
--include-json-body-shapes --json-body-schema-approved \
|
||||
--include-websocket-shapes --websocket-shape-approved \
|
||||
--include-script-sources \
|
||||
--include-storage-keys \
|
||||
--include-storage-keys --include-storage-fingerprints \
|
||||
--json
|
||||
```
|
||||
|
||||
Accessibility text and script content may contain application secrets and become part of the returned Evidence. Accessibility and console text have independent per-field and aggregate UTF-8 byte limits; credential-shaped console substrings are redacted. JSON and WebSocket captures retain paths, types, counts, and truncation only, never values or examples. Storage values remain redacted even when key-name capture is approved.
|
||||
Accessibility text and script content may contain application secrets and become part of the returned Evidence. Accessibility and console text have independent per-field and aggregate UTF-8 byte limits; credential-shaped console substrings are redacted. JSON and WebSocket captures retain paths, types, counts, and truncation only, never values or examples. Storage values remain redacted even when key-name capture is approved. `--include-storage-fingerprints` separately approves SHA-256 identity/value fingerprints, which support comparison only when cookies, DOM storage, IndexedDB, and Cache Storage were all captured completely.
|
||||
|
||||
Screenshot pixels require both `--approved` and `--screenshot-approved`:
|
||||
|
||||
@@ -122,7 +122,8 @@ rea capture-web-screenshot http://127.0.0.1:9222 TARGET_ID \
|
||||
"include_websocket_shapes": false,
|
||||
"websocket_shape_approved": false,
|
||||
"include_script_sources": false,
|
||||
"include_storage_keys": false
|
||||
"include_storage_keys": false,
|
||||
"include_storage_fingerprints": false
|
||||
}
|
||||
```
|
||||
|
||||
@@ -141,7 +142,7 @@ REA removes sensitive values before normalized event data is retained:
|
||||
- Request/response bodies are not requested or parsed by default. With independent approval, only allowed-origin JSON media types are read within per-body and aggregate budgets, converted immediately to value-free property paths/types, and discarded. `Network.getResponseBody` is never sent without that approval.
|
||||
- Console observations with an approved stack source retain call type, argument types, timestamp, and redacted source location. With independent approval, only already-delivered primitive values are retained after credential redaction and byte bounds; objects, getters, and remote properties are never expanded.
|
||||
- WebSocket observations retain direction, opcode, and payload byte length. With independent approval, bounded text frames are classified as text or value-free JSON shape; binary bytes, hashes, prefixes, and raw frames are never retained.
|
||||
- Storage observations always redact values. Key names, IndexedDB names, and cache names are omitted unless explicitly requested.
|
||||
- Storage observations always redact values. Key names, IndexedDB names, and cache names require `include_storage_keys`; stable content fingerprints require the additional `include_storage_fingerprints` approval. Cache bodies above 64 KiB and partial IndexedDB remote objects make fingerprint coverage incomplete, so identical observations remain `unknown`.
|
||||
- Script metadata is included only when CDP supplies a URL on an allowed origin. Stable keys exclude transient CDP script IDs, and exact transient raw URLs are used only during script/resource reconciliation. URL-less scripts are excluded because their origin cannot be established. When CDP supplies an execution-context association, the accepted script retains its authorized frame ID for later attribution. Source content is omitted unless explicitly requested and becomes a self-verifying `rea://web-content/sha256/...` artifact subject to per-script and aggregate byte limits.
|
||||
|
||||
Cross-origin frames, resources, scripts, events, and workers are excluded unless their exact origins are also approved. Excluded target details are counted without being exposed.
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# Browser scenario contract
|
||||
|
||||
`capture_browser_scenario` accepts the versioned provider-neutral
|
||||
`browserScenarioSchema` through both MCP and the
|
||||
`capture-browser-scenario INPUT_JSON` CLI command. Version 1 is declarative and
|
||||
bounded: it admits a fixed action vocabulary, exact HTTP(S) origins,
|
||||
deterministic browser settings, explicit storage and request replay, and finite
|
||||
capture limits. `INPUT_JSON` may be inline JSON or a JSON file path.
|
||||
|
||||
The browser boundary is part of the contract. Launch mode requires a
|
||||
caller-selected absolute executable, a provider-owned temporary profile,
|
||||
headless operation, and close-plus-delete cleanup. Connect mode accepts only an
|
||||
explicit-port loopback CDP endpoint, declares the browser externally owned, and
|
||||
permits disconnect-only cleanup. The Playwright driver preserves this
|
||||
distinction: it closes and deletes provider-owned profiles, but only disconnects
|
||||
from an external CDP browser. Real-browser verification checks both outcomes and
|
||||
confirms that an attached external browser remains alive.
|
||||
|
||||
URLs contain a query-free HTTP(S) base plus ordered query entries. Every query,
|
||||
form, storage, cookie, and replay value is either an explicitly public literal
|
||||
or a reference to a declared environment-backed secret. Raw credentials in
|
||||
URLs and literal credential headers are rejected. Secret query names must also
|
||||
appear in the redaction policy, and the required credential headers cannot be
|
||||
removed from that policy. Durable results must replace resolved secret values
|
||||
with their secret references.
|
||||
|
||||
Every start, navigation, storage, replay, and redirect origin must be listed in
|
||||
`allowed_origins`. Unsupported action tags, unknown fields, duplicate step or
|
||||
route IDs, undeclared or unused secrets, and provider-owned replay headers fail
|
||||
validation. HTTP and WebSocket routing enforces the same declared scope. Exact
|
||||
replay may abort unmatched requests or pass through only approved origins.
|
||||
|
||||
The result is Evidence v2 with an initial state followed by one record per
|
||||
declared action. Each step reports action status, elapsed time, sanitized URLs,
|
||||
event bounds, and independently typed capture states for screenshot, DOM,
|
||||
accessibility, URL, history, and storage. Console, page-error, network,
|
||||
WebSocket, frame, worker, popup, and cancelled-download events are attributed to
|
||||
steps and bounded by the scenario limits. Missing and truncated sections remain
|
||||
explicit and make the capture ineligible for equality claims. Attach-mode
|
||||
captures also declare the unavoidable pre-attach event gap.
|
||||
|
||||
Browser automation is disabled by default. The administrator ceiling is
|
||||
configured with:
|
||||
|
||||
- `REA_BROWSER_SCENARIO_ENABLED`
|
||||
- `REA_BROWSER_SCENARIO_EXECUTABLE_ROOTS_JSON`
|
||||
- `REA_BROWSER_SCENARIO_CDP_ENDPOINTS_JSON`
|
||||
- `REA_BROWSER_SCENARIO_ALLOWED_ORIGINS_JSON`
|
||||
- `REA_BROWSER_SCENARIO_ALLOWED_ENV_JSON`
|
||||
|
||||
Unlike passive observation, browser automation is not granted automatically.
|
||||
Use an explicit project/session grant, or opt into the administrator grant with
|
||||
`REA_BROWSER_SCENARIO_AUTO_GRANT=true` for a trusted unattended environment.
|
||||
Authorization commits the exact executable or CDP endpoint, origins, environment
|
||||
variable names, network class, and scenario digest before any browser side
|
||||
effect.
|
||||
|
||||
## Scenario comparison
|
||||
|
||||
`compare_web_captures` and `compare-web-captures` also accept two complete
|
||||
browser scenario captures. Steps align only by exact, unique `step_id`.
|
||||
Duplicate IDs, missing counterparts, changed action kinds, and incompatible
|
||||
browser/origin capture context are returned as alignment failures. They prevent
|
||||
an unchanged claim; observed differences may still prove a changed result.
|
||||
|
||||
The comparison records its full normalization commitment and SHA-256 digest.
|
||||
Built-in rules exclude step elapsed time and event sequence/index fields,
|
||||
compare screenshots by content digest, and compare DOM/accessibility captures
|
||||
by normalized text. Optional caller rules are bounded exact-literal
|
||||
replacements over named artifact kinds. Rules and artifact-kind lists are
|
||||
sorted canonically before application, making the same policy reproducible
|
||||
regardless of input order. Rules operate only on already-redacted durable
|
||||
capture fields and are preserved in the result.
|
||||
|
||||
Each aligned step reports changed, unchanged, or unknown. Changed and unknown
|
||||
action, screenshot, DOM, accessibility, URL, history, storage, and event
|
||||
artifacts carry their normalized before/after digests and capture states.
|
||||
`max_changes` bounds retained artifact records while total and omitted counts
|
||||
remain visible. Missing, truncated, or mismatched capture coverage remains
|
||||
unknown instead of being treated as equality.
|
||||
@@ -151,7 +151,7 @@ REA's Ghidra provider is bring-your-own and supports Linux x64 with the exact
|
||||
official Ghidra 12.1.2 release and a 64-bit full JDK 21. An experimental
|
||||
Windows x64 P0 supports approved native x86-64 PE applications. It supplies
|
||||
discovery, analysis-profile commitment, an isolated read-only headless session,
|
||||
ten bounded inventory/name/search operations, and eight function-analysis
|
||||
ten bounded inventory/name/search operations, and nine function-analysis
|
||||
operations covering metadata, decompilation, assembly, resolved calls, typed
|
||||
references, xrefs, CFG, and dossiers. GUI state and analysis mutations remain
|
||||
unavailable through Ghidra.
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
REA derives a bounded feature trace from one authenticated JavaScript
|
||||
Application Graph and compares two authenticated graph versions. The MCP tools
|
||||
are `trace_application_feature`, `trace_javascript_semantics`,
|
||||
`compare_application_versions`, and `compare_javascript_export_shapes`; their
|
||||
CLI equivalents use the same names with hyphens.
|
||||
`compare_application_versions`, `compare_source_to_bundle`, and
|
||||
`compare_javascript_export_shapes`; their CLI equivalents use the same names
|
||||
with hyphens.
|
||||
|
||||
Both workflows consume Evidence v2 produced by
|
||||
`analyze_javascript_application` or `reconcile_javascript_runtime`. They do not
|
||||
@@ -46,14 +47,22 @@ function, module, seed-match, and page limits are validated against exact
|
||||
published ranges. A committed cursor returns the next deterministic relation
|
||||
page only for the same graph, query, and limits.
|
||||
|
||||
The initial extractor covers lexical definitions and reads, closure captures,
|
||||
and uniquely resolved local calls with argument-to-parameter and direct
|
||||
return-to-call candidates. Dynamic calls/properties, parser recovery, and bound
|
||||
frontiers stay explicit. Promise, event, timer, child-process, request,
|
||||
configuration, boundary, resource, and fingerprint families are represented by
|
||||
the versioned schema but report unsupported or unknown coverage until their
|
||||
extractors supply facts. Missing relations in those families are never reported
|
||||
as proven absence.
|
||||
The extractor covers lexical definitions and reads, literal seeds, static
|
||||
property slots, object reads/writes/spreads/destructuring, closure captures,
|
||||
uniquely resolved local calls, argument/return flow, and explicit Promise
|
||||
construction, chaining, aggregation, await, return, assignment, and
|
||||
detachment. It also recovers bounded static candidates for EventEmitter
|
||||
registrations/removals and dispatch, Node timers and cancellation handles, asynchronous
|
||||
`node:child_process` creation and ownership, configuration sources and direct
|
||||
defaults, request construction and response consumers, parse/coercion/
|
||||
validation boundaries, and built-in resource acquisition/release.
|
||||
|
||||
Function fingerprints commit normalized syntax, control-flow shape, relation
|
||||
shape, literal sets, arity, and detected effects without using local names or
|
||||
source offsets. Equal duplicate fingerprints remain ambiguous. Dynamic
|
||||
calls/properties/names, parser recovery, unresolved handles, unsupported API
|
||||
shapes, and every reached bound stay explicit unknowns; a missing relation is
|
||||
never reported as proven behavioral absence.
|
||||
|
||||
Semantic relations use static-inference authority. `resolved` means the static
|
||||
target identity was unique within admitted analysis; it does not mean the path
|
||||
@@ -83,6 +92,25 @@ items, candidate references, graph nodes, edges, and observations. With
|
||||
`unknown_registry_approved: true`, unresolved comparison items can be retained
|
||||
as a residual unknown in a live session.
|
||||
|
||||
## Historical source-to-bundle comparison
|
||||
|
||||
`compare_source_to_bundle` compares one cryptographically committed
|
||||
`HistoricalSourceGraph/v1` with authenticated application-graph Evidence. The
|
||||
stable scoring model reports every admitted signal and weight: exact source
|
||||
digest, source-map original path, exact current path, path suffix, basename, and
|
||||
language extension. Exact digest wins over location inference; weak basename
|
||||
or extension evidence never forces a mapping.
|
||||
|
||||
Each historical source file is classified as `unchanged`, `modified`,
|
||||
`removed`, `split`, `merged`, `duplicated`, or `unknown`. Multiple equally
|
||||
scored path mappings form a bounded static split inference. Multiple current
|
||||
nodes with the exact historical digest are duplicated; multiple historical
|
||||
paths with exact identity to one current node are merged. `removed` requires
|
||||
complete historical and application inventories with no relevant comparison
|
||||
truncation. Ambiguous candidates, partial inventories, and exhausted limits
|
||||
remain unknown. These static mappings do not claim runtime loading or semantic
|
||||
equivalence.
|
||||
|
||||
## Export return-shape comparison
|
||||
|
||||
`compare_javascript_export_shapes` selects exactly one module path and export
|
||||
@@ -108,7 +136,7 @@ replay recommendation; it does not execute JavaScript.
|
||||
|
||||
## CLI and verification
|
||||
|
||||
All four CLI commands accept inline JSON or a JSON file up to 64 MiB. The input
|
||||
All five CLI commands accept inline JSON or a JSON file up to 64 MiB. The input
|
||||
is the same object used by the corresponding MCP tool.
|
||||
|
||||
For two operator-provided directories or ASARs, run:
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# Passive Node and Electron runtime observation
|
||||
|
||||
REA can attach to an already-running Node.js or Electron V8 Inspector target
|
||||
and retain bounded script-load and execution-context metadata. The
|
||||
`list_javascript_runtime_targets` and `observe_javascript_runtime` MCP tools
|
||||
have equivalent `rea list-javascript-runtime-targets` and
|
||||
`rea observe-javascript-runtime` commands. Successful calls return
|
||||
deterministic Evidence v2.
|
||||
|
||||
This authority is disabled by default and is separate from browser CDP,
|
||||
Electron file-page inspection, Process Capture, and controlled JavaScript
|
||||
replay:
|
||||
|
||||
```bash
|
||||
export REA_V8_INSPECTOR_OBSERVE_ENABLED=true
|
||||
export REA_V8_INSPECTOR_ENDPOINTS_JSON='["http://127.0.0.1:9229"]'
|
||||
export REA_V8_INSPECTOR_FILE_ROOTS_JSON='["/absolute/path/to/app"]'
|
||||
export REA_V8_INSPECTOR_ALLOWED_ORIGINS_JSON='[]'
|
||||
|
||||
rea list-javascript-runtime-targets http://127.0.0.1:9229 \
|
||||
--approved --json
|
||||
rea observe-javascript-runtime http://127.0.0.1:9229 TARGET_ID \
|
||||
--runtime-kind node --approved --json
|
||||
```
|
||||
|
||||
At least one canonical file root or exact HTTP(S) origin is required.
|
||||
Discovery is restricted to a literal-loopback HTTP endpoint. A target must
|
||||
match the exact requested target ID, expose a same-port WebSocket reported by
|
||||
the approved discovery endpoint, and remain inside the requested file or
|
||||
origin scope. Targets already marked attached are rejected rather than
|
||||
displacing another debugger. Canonical file paths are checked after symlink resolution.
|
||||
Excluded locations are counted but never retained.
|
||||
|
||||
## Passive protocol boundary
|
||||
|
||||
The provider sends exactly two protocol commands:
|
||||
|
||||
- `Runtime.enable`
|
||||
- `Debugger.enable`
|
||||
|
||||
It never sends `Runtime.evaluate`, reads script source, installs breakpoints,
|
||||
pauses or resumes execution, calls `Runtime.runIfWaitingForDebugger`, launches
|
||||
the target, instruments JavaScript, or invokes Electron APIs. Closing a call
|
||||
closes only REA's WebSocket.
|
||||
|
||||
The direct observations are:
|
||||
|
||||
- `Debugger.scriptParsed`, establishing that a bounded script location was
|
||||
parsed in the observed execution context;
|
||||
- `Debugger.scriptFailedToParse`, retained only as a bounded invalid-script
|
||||
count;
|
||||
- `Runtime.executionContextCreated`,
|
||||
`Runtime.executionContextDestroyed`, and
|
||||
`Runtime.executionContextsCleared`, establishing context lifecycle within
|
||||
the capture window.
|
||||
|
||||
The Inspector protocol does not directly expose require/import caller edges,
|
||||
EventEmitter emissions or listener invocation, Electron IPC messages or
|
||||
handlers, or script-unload events. Those facts remain explicit unknowns.
|
||||
`scriptParsed` proves script presence, not the importing module, feature
|
||||
execution, initialization order, or causality.
|
||||
|
||||
## Node and Electron roles
|
||||
|
||||
The caller declares one role: `node`, `electron-main`, `electron-preload`, or
|
||||
`electron-renderer`. Node and Electron-main declarations require a protocol
|
||||
target of type `node`; preload and renderer declarations require `page`.
|
||||
Inspector does not authenticate the operating-system PID or distinguish an
|
||||
Electron role, so Evidence records the role authority as
|
||||
`caller-declared-unverified`.
|
||||
|
||||
Electron main, preload, and renderer behavior can therefore be observed only
|
||||
as separate approved Inspector targets. The provider does not infer that two
|
||||
targets belong to the same Electron application.
|
||||
|
||||
## Bounds and determinism
|
||||
|
||||
Every observation commits the window and exact limits for events, scripts,
|
||||
execution contexts, per-location bytes, and total retained metadata bytes.
|
||||
Limit hits increment dropped-event counts and set `capture.truncated`; they
|
||||
never become completeness claims. Scripts are authorized after capture,
|
||||
deduplicated by stable metadata, and canonically sorted. Wall-clock timestamps
|
||||
and protocol script IDs are excluded from the durable result, so identical
|
||||
inputs and captured metadata produce the same Evidence ID.
|
||||
|
||||
Inspector attach is inherently incomplete. Enabling `Debugger` reports known
|
||||
and uncollected scripts, but scripts collected before attachment may be
|
||||
missing. Absence from a bounded capture never proves that a script or behavior
|
||||
does not occur.
|
||||
|
||||
## Static correlation
|
||||
|
||||
Pass the resulting observation Evidence and an
|
||||
`analyze_javascript_application` Evidence record to
|
||||
`reconcile_javascript_runtime`. The reconciliation accepts this provider
|
||||
alongside passive web and Electron page captures. Exact approved file or URL
|
||||
mappings can correlate script presence with JavaScript Application Graph
|
||||
assets. Because this provider never reads source bytes, matches normally use a
|
||||
unique authorized location and remain weaker than captured-byte identity.
|
||||
Builtin `node:` scripts remain runtime-only nodes unless a future explicit
|
||||
static authority models them.
|
||||
@@ -1,7 +1,7 @@
|
||||
# JavaScript static/runtime reconciliation
|
||||
|
||||
REA can combine an existing static JavaScript Application Graph with one or
|
||||
more passive browser or Electron captures without collapsing their different
|
||||
more passive browser, Electron page, or Node/Electron V8 Inspector captures without collapsing their different
|
||||
authorities. The `reconcile_javascript_runtime` MCP tool and
|
||||
`rea reconcile-javascript-runtime` CLI command accept Evidence v2 records and
|
||||
return derived Evidence v2 containing a combined JavaScript Application Graph
|
||||
@@ -9,7 +9,8 @@ and explicit match, ambiguity, mismatch, and unknown classifications.
|
||||
|
||||
The operation performs no filesystem, browser, or Electron I/O. It accepts only
|
||||
semantically verified Evidence produced by `analyze_javascript_application`,
|
||||
`inspect_web_page`, or `inspect_electron_page`; matching operation names alone
|
||||
`inspect_web_page`, `inspect_electron_page`, or
|
||||
`observe_javascript_runtime`; matching operation names alone
|
||||
is insufficient. Provider identity, predicate version, subject digest, target
|
||||
parameters, and normalized-result schemas are checked before reconciliation.
|
||||
|
||||
@@ -18,12 +19,13 @@ parameters, and normalized-result schemas are checked before reconciliation.
|
||||
1. Run `analyze_javascript_application` against exactly one application layer.
|
||||
Optional cache or assets directories can be analyzed independently and
|
||||
supplied as additional layers.
|
||||
2. Run `inspect_web_page` or `inspect_electron_page`. Capturing script sources
|
||||
2. Run `inspect_web_page`, `inspect_electron_page`, or the attach-only
|
||||
`observe_javascript_runtime`. Capturing script sources
|
||||
is optional, but an approved source capture provides the strongest byte
|
||||
identity.
|
||||
3. Pass the resulting Evidence records to `reconcile_javascript_runtime`.
|
||||
Browser URLs normally need an explicit URL-prefix mapping. Extracted
|
||||
Electron directories are mapped automatically when runtime files remain
|
||||
Electron and V8 Inspector file locations are mapped automatically when runtime files remain
|
||||
beneath the analyzed input root; relocated files, ASAR projections, caches,
|
||||
and asset roots need explicit mappings.
|
||||
|
||||
@@ -134,12 +136,19 @@ globally unloaded.
|
||||
|
||||
## Frames, workers, and source maps
|
||||
|
||||
Passive inspection now retains the execution-context frame ID supplied by CDP
|
||||
Passive inspection retains the execution-context frame ID supplied by CDP
|
||||
for each accepted script. Electron captures also inventory authorized worker,
|
||||
service-worker, and shared-worker targets, including bounded opener-target and
|
||||
parent-frame relationships. These fields improve attribution while preserving
|
||||
the original exact-origin or canonical-root filter.
|
||||
|
||||
V8 Inspector observations expose execution-context IDs but no authenticated
|
||||
frame relationship, so their scripts attach to the exact runtime target in the
|
||||
combined graph. They never supply source bytes, require/import caller edges,
|
||||
EventEmitter activity, Electron IPC, or script-unload evidence. A unique
|
||||
authorized file or URL location can still correlate script presence with a
|
||||
static asset, but it does not prove module initialization or feature execution.
|
||||
|
||||
Source-map declarations and approved original-source reads stay under static
|
||||
source-map authority. They are reported in `source_map_authority` but never used
|
||||
as primary runtime byte matches. A source name or `sourcesContent` digest is not
|
||||
|
||||
@@ -11,8 +11,14 @@ expected package version, catalog digest, or registered server path to
|
||||
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.
|
||||
Canonical tool names remain stable, while `tools/list` advertises only the
|
||||
operations callable for the current target, provider, policy, host, and
|
||||
negotiated client capabilities. `binary_session.tool_availability` remains the
|
||||
complete inventory: it explains advertised and hidden operations with stable
|
||||
availability reasons and remediation. Each entry also reports required and
|
||||
optional negotiated client features plus the currently missing features. Form elicitation is optional for
|
||||
`capture_process_scenario`: an existing grant remains usable without it, while
|
||||
a missing grant cannot be elicited by a client that does not advertise it.
|
||||
Opening or closing a target, reloading policy, or observing a provider health
|
||||
transition emits `notifications/tools/list_changed`.
|
||||
|
||||
|
||||
@@ -115,6 +115,37 @@ redacted from recorded text. The command-shim directory is placed first in the
|
||||
captured process `PATH`; any remaining `PATH` must be explicitly supplied or
|
||||
inherited under operator policy.
|
||||
|
||||
## Reactive process scenarios
|
||||
|
||||
Set `reactive` to a Process Reactive Scenario v1 declaration when interaction
|
||||
must follow observed output instead of guessed delays. The graph declares an
|
||||
initial state, absolute scenario and state deadlines, explicitly prioritized
|
||||
transitions, bounded uses and visits, trigger predicates, ordered actions, and
|
||||
either a next state or `passed` outcome.
|
||||
|
||||
The admitted runtime slice matches decoded UTF-8 terminal literals and exact
|
||||
`terminal_raw`, `interaction`, `process`, `filesystem`, `http`, `websocket`, or
|
||||
`shim` journal events. Process events are sampled observations, not an
|
||||
event-complete lifecycle. Filesystem events identify named checkpoints; shim
|
||||
events identify a command and route. Live predicates receive the same normalized
|
||||
and redacted payload later persisted in Evidence. Predicates may compose with
|
||||
bounded `all`, `any`, `sequence`, and `repeat` nodes and may select a frontier at
|
||||
scenario start, state entry, a prior checkpoint, or an exact event ID. Actions
|
||||
can write PTY input, resize it, signal the root process, or capture a named
|
||||
filesystem checkpoint. `terminal_rendered`, `lifecycle`, and `replay_transition`
|
||||
event triggers and non-root signal selectors fail during preflight before the
|
||||
target launches.
|
||||
|
||||
Reactive results are recorded in `reactive_run`, including the terminal outcome,
|
||||
active state, matched event IDs, action evidence IDs, and deterministic
|
||||
transition journal. Terminal controls also commit the last admitted observation
|
||||
order, so later output cannot be moved ahead of a timeout, cancellation, or
|
||||
target loss during validation. If the process exits before the graph finishes,
|
||||
the outcome is `target_lost`; state/scenario deadlines remain distinct
|
||||
`predicate_timeout`/`scenario_deadline` outcomes. Timed `events` remain
|
||||
available for fixed schedules, but they are not silently translated into the
|
||||
reactive graph.
|
||||
|
||||
## Checkpoints and deterministic shims
|
||||
|
||||
Checkpoint triggers may use an elapsed time, a terminal literal and occurrence
|
||||
|
||||
+88
-16
@@ -2,7 +2,7 @@
|
||||
"catalog_schema_version": 1,
|
||||
"package": {
|
||||
"name": "rea-agents",
|
||||
"version": "2.3.0",
|
||||
"version": "2.5.0",
|
||||
"sdk": {
|
||||
"server": "2.0.0-beta.4",
|
||||
"client": "2.0.0-beta.4",
|
||||
@@ -11,14 +11,15 @@
|
||||
"skill_version": "23"
|
||||
},
|
||||
"tools": {
|
||||
"total": 98,
|
||||
"total": 110,
|
||||
"families": [
|
||||
{
|
||||
"id": "direct",
|
||||
"surface": "official-proxy",
|
||||
"count": 33,
|
||||
"count": 36,
|
||||
"tools": [
|
||||
"address_name",
|
||||
"address_to_file_offset",
|
||||
"comment",
|
||||
"current_address",
|
||||
"current_document",
|
||||
@@ -40,6 +41,8 @@
|
||||
"procedure_info",
|
||||
"procedure_pseudo_code",
|
||||
"procedure_references",
|
||||
"read_bytes",
|
||||
"read_function_instructions",
|
||||
"resolve_containing_procedure",
|
||||
"search_procedures",
|
||||
"search_strings",
|
||||
@@ -56,17 +59,19 @@
|
||||
{
|
||||
"id": "enhanced",
|
||||
"surface": "enhanced",
|
||||
"count": 10,
|
||||
"count": 12,
|
||||
"tools": [
|
||||
"analyze_function",
|
||||
"analyze_swift_types",
|
||||
"batch_decompile",
|
||||
"binary_overview",
|
||||
"find_code_for_string",
|
||||
"find_xrefs_to_name",
|
||||
"get_call_graph",
|
||||
"get_objc_classes",
|
||||
"get_objc_protocols",
|
||||
"swift_classes",
|
||||
"trace_call_path",
|
||||
"trace_feature"
|
||||
]
|
||||
},
|
||||
@@ -85,8 +90,8 @@
|
||||
{
|
||||
"id": "artifact",
|
||||
"surface": "artifact-provider",
|
||||
"count": 2,
|
||||
"tools": ["extract_artifact", "inventory_artifact"]
|
||||
"count": 3,
|
||||
"tools": ["extract_artifact", "inspect_artifact", "inventory_artifact"]
|
||||
},
|
||||
{
|
||||
"id": "managed",
|
||||
@@ -106,9 +111,10 @@
|
||||
{
|
||||
"id": "browser",
|
||||
"surface": "browser-provider",
|
||||
"count": 8,
|
||||
"count": 9,
|
||||
"tools": [
|
||||
"analyze_web_bundle",
|
||||
"capture_browser_scenario",
|
||||
"capture_web_screenshot",
|
||||
"compare_web_captures",
|
||||
"compare_web_screenshots",
|
||||
@@ -129,14 +135,26 @@
|
||||
"reconcile_javascript_runtime"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "javascript-runtime",
|
||||
"surface": "runtime-provider",
|
||||
"count": 2,
|
||||
"tools": [
|
||||
"list_javascript_runtime_targets",
|
||||
"observe_javascript_runtime"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "application",
|
||||
"surface": "application-workflow",
|
||||
"count": 9,
|
||||
"count": 12,
|
||||
"tools": [
|
||||
"build_reconstruction_obligation_ledger",
|
||||
"commit_reconstruction_coverage",
|
||||
"compare_application_versions",
|
||||
"compare_javascript_export_shapes",
|
||||
"compare_source_to_bundle",
|
||||
"evaluate_reconstruction_readiness",
|
||||
"execute_node_characterization",
|
||||
"prepare_node_characterization",
|
||||
"query_reconstruction_coverage",
|
||||
@@ -193,6 +211,7 @@
|
||||
"procedure_info",
|
||||
"procedure_pseudo_code",
|
||||
"procedure_references",
|
||||
"read_function_instructions",
|
||||
"resolve_containing_procedure",
|
||||
"search_procedures",
|
||||
"search_strings",
|
||||
@@ -205,6 +224,7 @@
|
||||
"version": null,
|
||||
"capabilities": [
|
||||
"address_name",
|
||||
"address_to_file_offset",
|
||||
"analyze_function",
|
||||
"comment",
|
||||
"current_address",
|
||||
@@ -227,6 +247,8 @@
|
||||
"procedure_info",
|
||||
"procedure_pseudo_code",
|
||||
"procedure_references",
|
||||
"read_bytes",
|
||||
"read_function_instructions",
|
||||
"resolve_containing_procedure",
|
||||
"search_procedures",
|
||||
"search_strings",
|
||||
@@ -256,7 +278,11 @@
|
||||
"id": "rea-artifact-graph",
|
||||
"name": "REA safe artifact graph provider",
|
||||
"version": "1",
|
||||
"capabilities": ["extract_artifact", "inventory_artifact"]
|
||||
"capabilities": [
|
||||
"extract_artifact",
|
||||
"inspect_artifact",
|
||||
"inventory_artifact"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "rea-cdp-browser",
|
||||
@@ -312,9 +338,12 @@
|
||||
"name": "REA JavaScript application workflows",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"build_reconstruction_obligation_ledger",
|
||||
"commit_reconstruction_coverage",
|
||||
"compare_application_versions",
|
||||
"compare_javascript_export_shapes",
|
||||
"compare_source_to_bundle",
|
||||
"evaluate_reconstruction_readiness",
|
||||
"execute_node_characterization",
|
||||
"prepare_node_characterization",
|
||||
"query_reconstruction_coverage",
|
||||
@@ -328,6 +357,21 @@
|
||||
"name": "REA JavaScript runtime reconciliation",
|
||||
"version": "1",
|
||||
"capabilities": ["reconcile_javascript_runtime"]
|
||||
},
|
||||
{
|
||||
"id": "rea-playwright-browser-scenario",
|
||||
"name": "REA Playwright browser scenario capture provider",
|
||||
"version": "1",
|
||||
"capabilities": ["capture_browser_scenario"]
|
||||
},
|
||||
{
|
||||
"id": "rea-v8-inspector",
|
||||
"name": "REA passive Node/Electron V8 Inspector provider",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"list_javascript_runtime_targets",
|
||||
"observe_javascript_runtime"
|
||||
]
|
||||
}
|
||||
],
|
||||
"setup_clients": [
|
||||
@@ -387,6 +431,18 @@
|
||||
"id": "artifact_graph",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"id": "browser_scenario",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"id": "browser_scenario_capture",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"id": "browser_scenario_diff",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"id": "browser_target_list",
|
||||
"version": 1
|
||||
@@ -443,6 +499,14 @@
|
||||
"id": "javascript_feature_trace",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"id": "javascript_runtime_observation",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"id": "javascript_runtime_target_list",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"id": "javascript_semantic_graph",
|
||||
"version": 1
|
||||
@@ -517,12 +581,14 @@
|
||||
}
|
||||
],
|
||||
"cli": {
|
||||
"primary_count": 58,
|
||||
"primary_count": 66,
|
||||
"commands": [
|
||||
"analyze",
|
||||
"analyze-javascript-application",
|
||||
"analyze-web-bundle",
|
||||
"build-reconstruction-obligation-ledger",
|
||||
"capabilities",
|
||||
"capture-browser-scenario",
|
||||
"capture-process",
|
||||
"capture-web-screenshot",
|
||||
"commit-reconstruction-coverage",
|
||||
@@ -531,12 +597,14 @@
|
||||
"compare-javascript-export-shapes",
|
||||
"compare-managed-members",
|
||||
"compare-process-captures",
|
||||
"compare-source-to-bundle",
|
||||
"compare-web-captures",
|
||||
"compare-web-screenshots",
|
||||
"decompile",
|
||||
"demangle-swift",
|
||||
"discover-webmcp-tools",
|
||||
"doctor",
|
||||
"evaluate-reconstruction-readiness",
|
||||
"evidence-export",
|
||||
"evidence-import",
|
||||
"execute-node-characterization",
|
||||
@@ -545,6 +613,7 @@
|
||||
"import-managed-reconstruction",
|
||||
"import-reference-source",
|
||||
"inspect",
|
||||
"inspect-artifact",
|
||||
"inspect-electron-page",
|
||||
"inspect-macho",
|
||||
"inspect-managed-artifact",
|
||||
@@ -553,11 +622,14 @@
|
||||
"inspect-plist",
|
||||
"inspect-signature",
|
||||
"inspect-web-page",
|
||||
"instructions",
|
||||
"inventory-artifact",
|
||||
"investigate-versions",
|
||||
"list-architectures",
|
||||
"list-browser-targets",
|
||||
"list-electron-targets",
|
||||
"list-javascript-runtime-targets",
|
||||
"observe-javascript-runtime",
|
||||
"observe-web-session",
|
||||
"plan-managed-runtime-correlation",
|
||||
"policy",
|
||||
@@ -587,17 +659,17 @@
|
||||
},
|
||||
"runtime_catalog": {
|
||||
"counts": {
|
||||
"cli_commands": 58,
|
||||
"mcp_tools": 98,
|
||||
"cli_commands": 66,
|
||||
"mcp_tools": 110,
|
||||
"mcp_prompts": 6,
|
||||
"mcp_resources": 2,
|
||||
"mcp_resource_templates": 9
|
||||
"mcp_resource_templates": 11
|
||||
},
|
||||
"digests": {
|
||||
"tools_sha256": "501d405efbf8fc3537d13791f87ac81f3b97f3f328b62710ca0885de7213a62f",
|
||||
"tools_sha256": "9a033ddd40fcb8273365cc895bbf75d943bf1c5c8c2ee1eb9c4b3494064aaf58",
|
||||
"prompts_sha256": "f6da3ad38a1000c11364d1710f122ac4990551f04efd8c53ffb2e819b5cf5707",
|
||||
"resources_sha256": "94bebbab1a1b3cf47644c38d3de6469de568caa7bd7b842f529ead8ad320a912",
|
||||
"combined_sha256": "71de8a7174176823fd82c2666130233b5a1b4a1613583270d268774775b410bc"
|
||||
"resources_sha256": "339a984ea8cd8be5f234adf24395bf0295258d990492005cf6fe1f293d12cd46",
|
||||
"combined_sha256": "e8cac51f2f17a5b9a088c3ff773473c4897823401481fc0875a28fe7daf8eee0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ Status: the Ghidra read-only analysis provider is shipped on Linux x64 and has
|
||||
an experimental Windows x64 P0 for approved native PE applications. It
|
||||
validates an exact bring-your-own Ghidra 12.1.2/JDK 21 environment, resolves a
|
||||
provider/version/profile commitment, runs one isolated read-only headless
|
||||
import, and publishes 18 operation-level capabilities after the authenticated
|
||||
import, and publishes 19 operation-level capabilities after the authenticated
|
||||
post-analysis handshake.
|
||||
|
||||
The provider-neutral target, provider registry, deterministic target binding,
|
||||
@@ -69,14 +69,14 @@ Unix socket. Windows uses authenticated IPv4 loopback with a strict token-free
|
||||
endpoint record. Both transports report actual
|
||||
Ghidra/language/compiler/analysis/import-digest metadata, accept only the exact
|
||||
authenticated `ping`, `shutdown`, and ten
|
||||
inventory plus eight function-analysis methods, and deletes the socket. Close,
|
||||
inventory plus nine function-analysis methods, and deletes the socket. Close,
|
||||
cancellation, timeout,
|
||||
malformed protocol, or process exit stops the owned process resources and
|
||||
removes the entire runtime root. Windows P0 uses bounded `taskkill` tree
|
||||
termination and explicitly does not claim Job Object, private-DACL, or
|
||||
reparse-point authority; see [Windows Ghidra P0](windows-ghidra-p0.md).
|
||||
|
||||
The provider catalog lists only the 18 proved Ghidra operations. GUI cursor,
|
||||
The provider catalog lists only the 19 proved Ghidra operations. GUI cursor,
|
||||
navigation, and mutation operations remain absent; the router therefore
|
||||
reports them unavailable instead of borrowing Hopper semantics or inferring
|
||||
capability from a successful import.
|
||||
@@ -103,13 +103,14 @@ capability from a successful import.
|
||||
| Serialization | A bounded FIFO admits at most 32 active-plus-queued requests and sends one Program request at a time. Queue wait counts against the caller's deadline, and queued cancellation is prompt. This is an adapter safety commitment, not a claim that every Ghidra API is thread-safe. |
|
||||
| Function identity | Every function result carries the entry address and Ghidra FunctionManager classification for external, thunk, and resolved thunk target. These are observations, not proof that unresolved targetless calls have been recovered. |
|
||||
| Assembly and pseudocode | Assembly is bounded Ghidra Listing text; pseudocode is bounded Ghidra decompiler output. Neither is original source, and cross-provider comparison never treats Hopper and Ghidra text as equal or unequal semantic facts. |
|
||||
| Instruction fast path | `read_function_instructions` returns one offset-paginated raw Listing window without invoking the decompiler or whole-program name/string inventories. It reports scan work, exact-or-unknown totals, truncation, and continuation metadata. |
|
||||
| Calls and references | Callers/callees contain only resolved functions. Reference edges preserve exact ReferenceManager type and call/jump/data/read/write/indirect/computed/conditional/terminal/external facts. Targetless computed flow remains unknown. Synthetic entry-point references without actionable memory sources are omitted explicitly. |
|
||||
| CFG | Dossiers use `BasicBlockModel` and retain only non-call successors inside the function body. CFG topology is address-normalized for comparison; provider-specific block construction remains a declared difference. |
|
||||
| Bounds | Direct assembly fails above 100,000 instructions or the wire ceiling. Dossiers scan at most 5,000 requested instructions, return bounded independent pages, mark unknown totals after an incomplete scan, and paginate pseudocode by Unicode code points. |
|
||||
|
||||
`npm run verify:ghidra` compiles the versioned C oracles into x86-64 debug and
|
||||
stripped ELF, AArch64 ELF, x86-64 PE, and x86-64 Mach-O targets. It proves all
|
||||
18 operations, external functions, resolved thunks, exports, stripped-name
|
||||
19 operations, external functions, resolved thunks, exports, stripped-name
|
||||
behavior, direct and targetless indirect calls, typed references, strings/xrefs,
|
||||
multi-block CFG, semantic enhanced workflows, cancellation, deadlines,
|
||||
serialized concurrency, malformed-target rejection, profile identity, and
|
||||
@@ -118,7 +119,7 @@ cover analysis/decompile timeouts, process exit, queue saturation, and malformed
|
||||
wire output.
|
||||
|
||||
`npm run verify:ghidra:windows` generates a deterministic source-owned native
|
||||
x86-64 PE application, proves all 18 operations through the production Windows
|
||||
x86-64 PE application, proves all 19 operations through the production Windows
|
||||
launcher and loopback transport, checks target/snapshot/import SHA-256 linkage,
|
||||
and requires project, endpoint, process, and runtime cleanup. Passing that lane
|
||||
proves only the documented P0 boundary, not the remaining Windows security
|
||||
@@ -146,7 +147,7 @@ only the generic process mechanisms.
|
||||
|
||||
| Provider | License / automation surface | What it brings | REA fit and blockers |
|
||||
| ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [Ghidra](https://github.com/NationalSecurityAgency/ghidra) | Apache-2.0 source license; `analyzeHeadless`, Java APIs, and PyGhidra | Broad static analysis, many processors and formats, scripting, and project/database workflows | Read-only analysis shipped: exact BYO checks, isolated state, bounded process and queue, packaged bridge, 18 admitted inventory/function operations, and real ELF/PE/Mach-O x86-64 plus AArch64 conformance. GUI and mutation semantics remain intentionally unavailable. |
|
||||
| [Ghidra](https://github.com/NationalSecurityAgency/ghidra) | Apache-2.0 source license; `analyzeHeadless`, Java APIs, and PyGhidra | Broad static analysis, many processors and formats, scripting, and project/database workflows | Read-only analysis shipped: exact BYO checks, isolated state, bounded process and queue, packaged bridge, 19 admitted inventory/function operations, and real ELF/PE/Mach-O x86-64 plus AArch64 conformance. GUI and mutation semantics remain intentionally unavailable. |
|
||||
| [Rizin](https://github.com/rizinorg/rizin) / [rz-pipe](https://github.com/rizinorg/rz-pipe) | Rizin repository contains LGPL-3.0 and GPL-3.0 components; `rizin`, `rz-bin`, and language bridges through `rzpipe` | Portable CLI analysis, disassembly/debugging, many architectures and file formats, JSON command output | Good candidate for a process-backed Linux provider and fast metadata fallback. License/component inventory must be preserved; command output needs version-pinned parsers and semantic conformance before evidence is trusted. |
|
||||
| [LIEF](https://github.com/lief-project/LIEF) | Apache-2.0; C++, Python, and other bindings | Deterministic parsing and modification of ELF, PE, Mach-O, COFF, and related executable formats; headers, sections, symbols, relocations, and functions | Best near-term complement, not a decompiler replacement. It can cover format metadata and artifact evidence without a long-lived analysis process; function semantics, pseudocode, CFG, and cross-reference parity remain out of scope unless separately demonstrated. |
|
||||
| [Binary Ninja](https://docs.binary.ninja/dev/index.html) | API/documentation components are MIT, while the analysis product is licensed by edition; commercial, Ultimate, or Headless license is required for headless automation | Python/Core/C++/Rust APIs, headless loading, IL layers, function analysis, plugins, and configurable analysis | Strong technical fit for a native provider, especially function dossiers. Commercial licensing, license-secret handling, native runtime packaging, and multithreaded lifecycle rules are material deployment blockers. |
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
# Reconstruction obligation ledgers
|
||||
|
||||
`build_reconstruction_obligation_ledger` turns authenticated Evidence v2 records
|
||||
into a versioned, deterministic list of reconstruction claims. The same workflow
|
||||
is available as:
|
||||
|
||||
- MCP tool: `build_reconstruction_obligation_ledger`
|
||||
- CLI command: `rea build-reconstruction-obligation-ledger`
|
||||
- MCP resource:
|
||||
`rea://evidence/{evidenceId}/reconstruction-obligation-ledger`
|
||||
|
||||
The ledger is conservative by design. Static Application Graph facts create
|
||||
candidate obligations; they do not prove runtime or process behavior. A required
|
||||
obligation closes only when one manifest binding supplies a unique owner, any
|
||||
required parser/schema/domain type, every required case fixture, and a passing
|
||||
verifier whose authority is comparable to the original observation. The
|
||||
verifier must enumerate the obligation ID and its result must be present in the
|
||||
input Evidence bundle. Contradictions, duplicate definitions or owners,
|
||||
residual unknowns, missing dependencies, and unavailable authority keep closure
|
||||
open or failed.
|
||||
|
||||
## Complete CLI request
|
||||
|
||||
This empty request is useful for checking client integration. It is valid and
|
||||
returns an `unknown` ledger with zero obligations; absence of source Evidence
|
||||
never claims closure:
|
||||
|
||||
```sh
|
||||
rea build-reconstruction-obligation-ledger \
|
||||
'{
|
||||
"evidence_bundle": {
|
||||
"bundle_version": 2,
|
||||
"artifacts": [],
|
||||
"providers": [],
|
||||
"environments": [],
|
||||
"scenarios": [],
|
||||
"captures": [],
|
||||
"unknowns": [],
|
||||
"records": []
|
||||
},
|
||||
"reviewed_obligations": [],
|
||||
"manifest": {
|
||||
"schema_version": 1,
|
||||
"bindings": [],
|
||||
"contradictions": []
|
||||
},
|
||||
"limits": { "max_obligations": 100 },
|
||||
"page": { "offset": 0, "limit": 50 }
|
||||
}' \
|
||||
--json
|
||||
```
|
||||
|
||||
For a real investigation, export an Evidence bundle from the session and replace
|
||||
the empty bundle. Application Graph and Process Capture v4 records generate
|
||||
conservative candidates automatically. Add reviewed obligations when a required
|
||||
claim is not represented by those sources.
|
||||
|
||||
## Reviewed obligation examples
|
||||
|
||||
Each example below is a complete `reviewed_obligations` element. Replace the
|
||||
sample Evidence ID with an authenticated review record already present in
|
||||
`evidence_bundle.records`. Stable IDs and versions make changes reviewable.
|
||||
|
||||
### CLI parsing
|
||||
|
||||
```json
|
||||
{
|
||||
"obligation_id": "cli.config.invalid-option",
|
||||
"obligation_version": 1,
|
||||
"title": "Reject an invalid configuration option with the supported CLI error",
|
||||
"application_layer": "cli",
|
||||
"family": "argument-parsing",
|
||||
"target": {
|
||||
"artifact_sha256": null,
|
||||
"application_node_id": null,
|
||||
"semantic_node_id": null,
|
||||
"location": "rea --unknown-option"
|
||||
},
|
||||
"required": true,
|
||||
"required_case_kinds": ["positive", "negative", "malformed"],
|
||||
"required_original_authority": "process",
|
||||
"required_fixture_authority": "packaged-process",
|
||||
"required_verifier_authority": "packaged-process",
|
||||
"requires_parser_type": true,
|
||||
"dependency_obligation_ids": [],
|
||||
"residual_unknown_ids": [],
|
||||
"unavailable_authority": [],
|
||||
"required_next_evidence": ["Capture the installed CLI for all three cases."],
|
||||
"disposition": "active",
|
||||
"review_evidence_ids": [
|
||||
"ev_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Protocol handler
|
||||
|
||||
```json
|
||||
{
|
||||
"obligation_id": "protocol.rpc.cancel",
|
||||
"obligation_version": 1,
|
||||
"title": "Cancel an in-flight request and emit one terminal response",
|
||||
"application_layer": "protocol",
|
||||
"family": "request-response",
|
||||
"target": {
|
||||
"artifact_sha256": null,
|
||||
"application_node_id": null,
|
||||
"semantic_node_id": null,
|
||||
"location": "JSON-RPC request cancellation"
|
||||
},
|
||||
"required": true,
|
||||
"required_case_kinds": ["positive", "negative", "malformed", "cancellation"],
|
||||
"required_original_authority": "external",
|
||||
"required_fixture_authority": "protocol",
|
||||
"required_verifier_authority": "protocol",
|
||||
"requires_parser_type": true,
|
||||
"dependency_obligation_ids": [],
|
||||
"residual_unknown_ids": [],
|
||||
"unavailable_authority": [],
|
||||
"required_next_evidence": [
|
||||
"Record request, cancellation, and terminal response IDs."
|
||||
],
|
||||
"disposition": "active",
|
||||
"review_evidence_ids": [
|
||||
"ev_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Electron startup and background behavior
|
||||
|
||||
```json
|
||||
{
|
||||
"obligation_id": "electron.background.startup",
|
||||
"obligation_version": 1,
|
||||
"title": "Start the background process before exposing the renderer bridge",
|
||||
"application_layer": "electron",
|
||||
"family": "startup",
|
||||
"target": {
|
||||
"artifact_sha256": null,
|
||||
"application_node_id": null,
|
||||
"semantic_node_id": null,
|
||||
"location": "main process startup"
|
||||
},
|
||||
"required": true,
|
||||
"required_case_kinds": ["positive", "negative", "teardown"],
|
||||
"required_original_authority": "runtime",
|
||||
"required_fixture_authority": "renderer",
|
||||
"required_verifier_authority": "renderer",
|
||||
"requires_parser_type": false,
|
||||
"dependency_obligation_ids": [],
|
||||
"residual_unknown_ids": [],
|
||||
"unavailable_authority": [],
|
||||
"required_next_evidence": [
|
||||
"Observe main/renderer ordering and application shutdown."
|
||||
],
|
||||
"disposition": "active",
|
||||
"review_evidence_ids": [
|
||||
"ev_cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Persistence migration
|
||||
|
||||
```json
|
||||
{
|
||||
"obligation_id": "persistence.profile.migrate-v2",
|
||||
"obligation_version": 1,
|
||||
"title": "Migrate a version-one profile without losing unknown fields",
|
||||
"application_layer": "persistence",
|
||||
"family": "migration",
|
||||
"target": {
|
||||
"artifact_sha256": null,
|
||||
"application_node_id": null,
|
||||
"semantic_node_id": null,
|
||||
"location": "profile schema v1 to v2"
|
||||
},
|
||||
"required": true,
|
||||
"required_case_kinds": ["positive", "negative", "malformed"],
|
||||
"required_original_authority": "runtime",
|
||||
"required_fixture_authority": "integration",
|
||||
"required_verifier_authority": "integration",
|
||||
"requires_parser_type": true,
|
||||
"dependency_obligation_ids": [],
|
||||
"residual_unknown_ids": [],
|
||||
"unavailable_authority": [],
|
||||
"required_next_evidence": [
|
||||
"Compare pre-migration and committed storage snapshots."
|
||||
],
|
||||
"disposition": "active",
|
||||
"review_evidence_ids": [
|
||||
"ev_dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Native ABI
|
||||
|
||||
```json
|
||||
{
|
||||
"obligation_id": "native-abi.helper.status",
|
||||
"obligation_version": 1,
|
||||
"title": "Preserve the helper status-code and output-buffer contract",
|
||||
"application_layer": "native-abi",
|
||||
"family": "ffi",
|
||||
"target": {
|
||||
"artifact_sha256": null,
|
||||
"application_node_id": null,
|
||||
"semantic_node_id": null,
|
||||
"location": "helper_status(const uint8_t *, size_t, uint8_t *, size_t *)"
|
||||
},
|
||||
"required": true,
|
||||
"required_case_kinds": ["positive", "negative", "malformed"],
|
||||
"required_original_authority": "process",
|
||||
"required_fixture_authority": "native-abi",
|
||||
"required_verifier_authority": "native-abi",
|
||||
"requires_parser_type": true,
|
||||
"dependency_obligation_ids": [],
|
||||
"residual_unknown_ids": [],
|
||||
"unavailable_authority": [],
|
||||
"required_next_evidence": [
|
||||
"Run the ABI harness against the shipped and reconstructed libraries."
|
||||
],
|
||||
"disposition": "active",
|
||||
"review_evidence_ids": [
|
||||
"ev_eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Manifest and closure
|
||||
|
||||
A manifest binding names one owner and its exact implementation digest. Both
|
||||
`original_cases` and the reconstruction fixture list must cover every
|
||||
`required_case_kinds` value with authenticated, comparable Evidence.
|
||||
`parser_type` is mandatory when `requires_parser_type` is true.
|
||||
|
||||
```json
|
||||
{
|
||||
"obligation_id": "protocol.rpc.cancel",
|
||||
"owner": {
|
||||
"module_path": "src/protocol/requestRouter.ts",
|
||||
"symbol": "cancelRequest",
|
||||
"owner_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
|
||||
},
|
||||
"parser_type": {
|
||||
"parser": "parseCancelRequest",
|
||||
"schema": "cancelRequestSchema",
|
||||
"domain_type": "CancelRequest"
|
||||
},
|
||||
"original_cases": [
|
||||
{
|
||||
"kind": "positive",
|
||||
"evidence_id": "ev_6666666666666666666666666666666666666666666666666666666666666666",
|
||||
"location": "/captures/positive"
|
||||
},
|
||||
{
|
||||
"kind": "negative",
|
||||
"evidence_id": "ev_7777777777777777777777777777777777777777777777777777777777777777",
|
||||
"location": "/captures/negative"
|
||||
},
|
||||
{
|
||||
"kind": "malformed",
|
||||
"evidence_id": "ev_8888888888888888888888888888888888888888888888888888888888888888",
|
||||
"location": "/captures/malformed"
|
||||
},
|
||||
{
|
||||
"kind": "cancellation",
|
||||
"evidence_id": "ev_9999999999999999999999999999999999999999999999999999999999999999",
|
||||
"location": "/captures/cancellation"
|
||||
}
|
||||
],
|
||||
"fixtures": [
|
||||
{
|
||||
"fixture_id": "rpc.cancel.positive",
|
||||
"case_kind": "positive",
|
||||
"authority": "protocol",
|
||||
"evidence_ids": [
|
||||
"ev_1111111111111111111111111111111111111111111111111111111111111111"
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixture_id": "rpc.cancel.negative",
|
||||
"case_kind": "negative",
|
||||
"authority": "protocol",
|
||||
"evidence_ids": [
|
||||
"ev_2222222222222222222222222222222222222222222222222222222222222222"
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixture_id": "rpc.cancel.malformed",
|
||||
"case_kind": "malformed",
|
||||
"authority": "protocol",
|
||||
"evidence_ids": [
|
||||
"ev_3333333333333333333333333333333333333333333333333333333333333333"
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixture_id": "rpc.cancel.cancellation",
|
||||
"case_kind": "cancellation",
|
||||
"authority": "protocol",
|
||||
"evidence_ids": [
|
||||
"ev_4444444444444444444444444444444444444444444444444444444444444444"
|
||||
]
|
||||
}
|
||||
],
|
||||
"verifier": {
|
||||
"verifier_id": "rpc.cancel.conformance",
|
||||
"claim_id": "protocol.rpc.cancel.v1",
|
||||
"command": "npm run test:protocol -- cancel",
|
||||
"authority": "protocol",
|
||||
"status": "pass",
|
||||
"result_evidence_id": "ev_5555555555555555555555555555555555555555555555555555555555555555",
|
||||
"enumerated_obligation_ids": ["protocol.rpc.cancel"],
|
||||
"nondeterminism": {
|
||||
"mode": "partial-order",
|
||||
"specification": "cancel precedes the single terminal response"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
All referenced Evidence IDs must exist in the bundle. A green unit test cannot
|
||||
close a `packaged-process` obligation. A static-only source cannot close a
|
||||
runtime or process claim. Marking a claim `blocked` or `out-of-scope` records the
|
||||
disposition but does not silently count it as verified.
|
||||
|
||||
Pagination changes only `page` and the returned `obligations` slice. Every page
|
||||
retains the same `ledger_id`, `closure_digest`, full-ledger summaries, reports,
|
||||
ownership graph, dependency graph, Evidence links, and limitations. Consumers
|
||||
should follow `next_offset` until it is `null`, and should reject pages whose
|
||||
identity or closure digest changes mid-read.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Reconstruction readiness conformance
|
||||
|
||||
Run the complete source-owned native, JavaScript CLI, Electron, Evidence,
|
||||
comparison, obligation, replay, CLI, and MCP journey from a clean checkout:
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run verify:readiness
|
||||
```
|
||||
|
||||
The command builds the redistributable native conformance fixtures, inventories
|
||||
the native executable through the public CLI, analyzes the generated JavaScript
|
||||
CLI and Electron fixture through both CLI and MCP, verifies exact advertised
|
||||
limits, evaluates the same nine-stage report through both surfaces, reads the
|
||||
full MCP report resource, and proves stale/tampered replay rejection. It prints
|
||||
one compact JSON summary.
|
||||
|
||||
Set `REA_READINESS_REPORT_PATH` to retain the complete machine-readable report:
|
||||
|
||||
```sh
|
||||
REA_READINESS_REPORT_PATH=readiness-report.json npm run verify:readiness
|
||||
```
|
||||
|
||||
CI runs this command on Linux, publishes the compact summary, and retains the
|
||||
full report as the `reconstruction-readiness` artifact.
|
||||
|
||||
## Public contracts
|
||||
|
||||
- CLI: `rea evaluate-reconstruction-readiness <json-or-file> --json`
|
||||
- MCP tool: `evaluate_reconstruction_readiness`
|
||||
- Full MCP resource:
|
||||
`rea://evidence/{evidenceId}/reconstruction-readiness-report`
|
||||
|
||||
The MCP tool result contains only report identity, status, summary, metrics, and
|
||||
the resource URI. The full Evidence bundle, stage records, comparisons,
|
||||
contradictions, obligation ledger, and replay inputs stay in the retained
|
||||
resource instead of consuming the tool-response context.
|
||||
|
||||
## Required stages
|
||||
|
||||
| Stage | Required proof |
|
||||
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| `discover-classify` | target-free startup, artifact inventory, compatible workflow selection, and exact visible limits |
|
||||
| `diagnose-environment` | broken runtime separated from analysis failure, version skew detected, and revision changed after recovery |
|
||||
| `acquire-authority` | exact missing scope, narrow grant, expansion re-prompt, and no launch after denial/cancellation |
|
||||
| `static-analysis` | native and JavaScript routing plus preserved ambiguity, truncation, unsupported syntax, and residual unknowns |
|
||||
| `reactive-scenarios` | predicate-driven interaction, descendant settlement, Electron correlation, and cancellation cleanup Evidence |
|
||||
| `compare-authority-candidate` | explicit partial-order/finite-trace semantics, primary divergence localization, and unknown for incomplete comparison |
|
||||
| `preserve-contradictions` | record-and-continue, affected equivalence blocked, and unaffected comparison retained |
|
||||
| `verify-reconstruction-closure` | incomplete reconstruction detected, incremental verifier closure, and authority-delegating candidates rejected |
|
||||
| `export-replay` | CLI/MCP parity, process/structured failure parity, deterministic replay, tamper rejection, and stale-input rejection |
|
||||
|
||||
Every stage and required check must occur exactly once and link to Evidence in
|
||||
the embedded Evidence v2 bundle. `fail`, `unknown`, `unsupported`, `truncated`,
|
||||
and `skipped` never aggregate to `pass`.
|
||||
|
||||
## Fail-closed rules
|
||||
|
||||
The evaluator rejects or withholds readiness when:
|
||||
|
||||
- a bounded capability omits its effective numeric limits;
|
||||
- a fixture lacks exactly one compatible selected workflow;
|
||||
- an incompatible or unavailable provider was invoked;
|
||||
- a typed CLI failure exits zero, or an MCP failure reports success;
|
||||
- concurrent behavior uses total-order comparison;
|
||||
- truncation, instability, unavailable authority, or contradiction is labeled
|
||||
equivalent;
|
||||
- a deliberate primary divergence is not localized;
|
||||
- contradiction Evidence or its affected comparison is missing;
|
||||
- the reconstruction obligation ledger is not `ready`, has no required
|
||||
obligation, or retains an unowned/unverified required obligation;
|
||||
- denied authority launches a process, cancellation loses diagnostics, or owned
|
||||
resources remain;
|
||||
- replay is nondeterministic, tampering/staleness is not detected, or the
|
||||
expected source digest differs.
|
||||
|
||||
Each finding names its stage, stable reason code, Evidence IDs, and detail.
|
||||
Stage `capability_issue` and `next_action` fields map unsupported or failing
|
||||
work to the responsible capability and remediation without claiming that the
|
||||
requested analysis ran.
|
||||
|
||||
## Fixtures
|
||||
|
||||
The verifier uses only source-owned, redistributable fixtures:
|
||||
|
||||
- `tests/conformance` native executables with stable symbols and deliberate
|
||||
version divergence;
|
||||
- `tests/conformance/readiness/javascript-cli`, which exposes prompts,
|
||||
alternate-screen behavior, configuration precedence, child process,
|
||||
persistence, loopback HTTP, and signal teardown to static reconstruction;
|
||||
- `tests/conformance/readiness/electron`, which exposes main, preload,
|
||||
renderer, utility, IPC, renderer restart, blocked shell effect, and deep-link
|
||||
surfaces;
|
||||
- the shared incomplete reconstruction and broken-runtime observations in
|
||||
`RECONSTRUCTION_READINESS_EXAMPLE`.
|
||||
|
||||
The synthetic example is a public typed input, not a global equivalence claim.
|
||||
It demonstrates that every deliberate fault remains represented in the final
|
||||
report and that only comparable Evidence can close the associated obligation.
|
||||
+2
-2
@@ -11,11 +11,11 @@ additively; Devin is reported but left unchanged.
|
||||
|
||||
The Ghidra foundation supports Linux x64 with exact Ghidra 12.1.2 and a 64-bit
|
||||
full JDK 21. An experimental Windows x64 P0 admits approved native x86-64 PE
|
||||
applications through the same 18-operation Java bridge. Doctor validates those coordinates; approved Linux/macOS setup propagates them
|
||||
applications through the same 19-operation Java bridge. Doctor validates those coordinates; approved Linux/macOS setup propagates them
|
||||
to MCP registrations without installing or modifying either dependency. REA's
|
||||
packaged Java bridge then proves an isolated read-only headless import,
|
||||
post-analysis handshake, complete cleanup, ten admitted read-only inventory
|
||||
operations, and eight function-analysis operations. Real source-owned x86-64
|
||||
operations, and nine function-analysis operations. Real source-owned x86-64
|
||||
debug/stripped ELF, AArch64 ELF, PE, and Mach-O fixtures cover program,
|
||||
procedure, string, symbol, external/thunk, memory, resolution, search,
|
||||
decompilation, assembly, calls, typed references, xrefs, and CFG semantics.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"manifest_id": "ecm_e04a25d07a540df3bec2fd5ba6d8a158e1f582dc94fc59225c7ba329cbabbbf6",
|
||||
"manifest_id": "ecm_ee8e5f2f4ea090052051d2994cbff148d9c796e1bc28b2fdc145aa5fa48cd248",
|
||||
"verifier": {
|
||||
"id": "rea-managed-conformance",
|
||||
"version": "1"
|
||||
@@ -14,7 +14,7 @@
|
||||
"skill_digests": [
|
||||
{
|
||||
"skill_id": "reverse-engineer-anything",
|
||||
"sha256": "a8ffb68f043fc9dfd0eb401caa6e548adce991d1e228b4539333cf90be17a755"
|
||||
"sha256": "6a910f067c946d545f5eb711eacbdf804839dbfb6b3b7b6ea91769be37ae47ce"
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
|
||||
@@ -13,7 +13,7 @@ The P0 accepts exactly:
|
||||
- an operator-installed official Ghidra 12.1.2 distribution;
|
||||
- a 64-bit full JDK 21;
|
||||
- an explicit native, non-managed, non-DLL x86-64 PE application; and
|
||||
- the existing 18 read-only Ghidra inventory and function-analysis operations.
|
||||
- the existing 19 read-only Ghidra inventory and function-analysis operations.
|
||||
|
||||
Hopper, Ghidra GUI state, mutation, controlled JavaScript replay, process
|
||||
capture, browser/Electron observation, artifact extraction, and general
|
||||
@@ -146,7 +146,7 @@ npm run verify:ghidra:windows
|
||||
|
||||
The verifier creates an ignored deterministic x86-64 PE fixture, checks its
|
||||
fixed SHA-256, opens Ghidra 12.1.2 through the production Java bridge, exercises
|
||||
all 18 admitted operations, verifies the target/snapshot/import digest chain,
|
||||
all 19 admitted operations, verifies the target/snapshot/import digest chain,
|
||||
and checks endpoint, project, process, and runtime cleanup. The GitHub workflow
|
||||
accepts only the fixed `real-ghidra-windows` repository-dispatch event against
|
||||
default-branch `main`, a protected `real-ghidra-windows` environment, and a
|
||||
|
||||
Generated
+15
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "rea-agents",
|
||||
"version": "2.3.0",
|
||||
"version": "2.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "rea-agents",
|
||||
"version": "2.3.0",
|
||||
"version": "2.5.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "8.0.4",
|
||||
@@ -28,6 +28,7 @@
|
||||
"incur": "0.4.13",
|
||||
"isomorphic-git": "1.38.7",
|
||||
"pino": "9.14.0",
|
||||
"playwright-core": "1.58.2",
|
||||
"plist": "5.0.0",
|
||||
"semver": "7.8.5",
|
||||
"smol-toml": "^1.7.0",
|
||||
@@ -4311,6 +4312,18 @@
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/plist": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/plist/-/plist-5.0.0.tgz",
|
||||
|
||||
+12
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "rea-agents",
|
||||
"version": "2.3.0",
|
||||
"version": "2.5.0",
|
||||
"description": "Reverse engineer anything from your terminal or agent with one CLI and MCP server.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
@@ -80,25 +80,29 @@
|
||||
"test": "vitest run",
|
||||
"test:run": "vitest run",
|
||||
"pretest:fast": "npm run build:cached",
|
||||
"test:fast": "vitest run --project parallel",
|
||||
"test:fast": "vitest run --project parallel --project subprocess",
|
||||
"pretest:changed": "npm run build:cached",
|
||||
"test:changed": "vitest run --project parallel --project subprocess --changed",
|
||||
"pretest:integration": "npm run build:cached",
|
||||
"test:integration": "vitest run --project serial-integration",
|
||||
"pretest:coverage": "npm run build:cached",
|
||||
"test:coverage": "vitest run --coverage.enabled --coverage.provider=v8",
|
||||
"test:ci": "npm run test:coverage -- --reporter=default --reporter=junit --outputFile.junit=test-results.xml",
|
||||
"pretest:ci:shard": "npm run build:cached",
|
||||
"test:ci:shard": "vitest run --coverage.enabled --coverage.provider=v8 --coverage.thresholds.lines=0 --coverage.thresholds.functions=0 --coverage.thresholds.branches=0 --coverage.thresholds.statements=0 --reporter=default --reporter=blob",
|
||||
"test:ci:shard": "npm run test:ci:shard:run",
|
||||
"test:ci:shard:run": "vitest run --coverage.enabled --coverage.provider=v8 --coverage.thresholds.lines=0 --coverage.thresholds.functions=0 --coverage.thresholds.branches=0 --coverage.thresholds.statements=0 --reporter=default --reporter=blob",
|
||||
"test:ci:merge": "vitest --merge-reports --coverage.enabled --coverage.provider=v8 --reporter=default --reporter=junit --outputFile.junit=test-results.xml",
|
||||
"pretest:verbose": "npm run build:cached",
|
||||
"test:verbose": "vitest run --reporter=verbose",
|
||||
"pretest:watch": "npm run build:cached",
|
||||
"test:watch": "vitest --project parallel --changed",
|
||||
"test:watch": "vitest --project parallel --project subprocess --changed",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"verify:test-temp-hygiene": "npm run build:cached && node scripts/verify-test-temp-hygiene.mjs",
|
||||
"verify:hopper": "npm run verify:fixtures && npm run build:cached && node scripts/verify-real-hopper.mjs",
|
||||
"verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs",
|
||||
"verify:ghidra:windows": "npm run build:cached && npm run build:ghidra-windows-fixture && node scripts/verify-real-ghidra-windows.mjs",
|
||||
"verify:browser": "npm run build:cached && node scripts/verify-real-browser.mjs",
|
||||
"verify:inspector": "npm run build:cached && node scripts/verify-real-javascript-runtime-observation.mjs",
|
||||
"verify:javascript-runtime": "npm run build:cached && node scripts/verify-local-javascript-runtime.mjs",
|
||||
"verify:application-workflows": "npm run build:cached && node scripts/verify-local-application-workflows.mjs",
|
||||
"verify:managed": "npm run build:cached && node scripts/verify-managed-conformance.mjs",
|
||||
@@ -109,6 +113,7 @@
|
||||
"build:fixtures": "node scripts/build-conformance-fixtures.mjs",
|
||||
"build:ghidra-windows-fixture": "node scripts/create-ghidra-windows-fixture.mjs",
|
||||
"verify:fixtures": "npm run build:fixtures && node scripts/verify-conformance-fixtures.mjs",
|
||||
"verify:readiness": "npm run build:cached && npm run verify:fixtures && node scripts/verify-reconstruction-readiness.mjs",
|
||||
"verify:package": "npm run build:cached && node scripts/verify-package.mjs",
|
||||
"verify:package:windows": "npm run build:cached && node scripts/verify-windows-package.mjs",
|
||||
"probe:windows-capabilities": "npm run build:cached && node scripts/windows-capabilities.mjs",
|
||||
@@ -139,8 +144,8 @@
|
||||
"@electron/asar": "4.2.0",
|
||||
"@jridgewell/trace-mapping": "0.3.31",
|
||||
"@lydell/node-pty": "1.1.0",
|
||||
"@modelcontextprotocol/server": "2.0.0-beta.4",
|
||||
"@modelcontextprotocol/client": "2.0.0-beta.4",
|
||||
"@modelcontextprotocol/server": "2.0.0-beta.4",
|
||||
"@xterm/addon-serialize": "0.14.0",
|
||||
"@xterm/headless": "6.0.0",
|
||||
"@zip.js/zip.js": "2.8.26",
|
||||
@@ -152,6 +157,7 @@
|
||||
"incur": "0.4.13",
|
||||
"isomorphic-git": "1.38.7",
|
||||
"pino": "9.14.0",
|
||||
"playwright-core": "1.58.2",
|
||||
"plist": "5.0.0",
|
||||
"semver": "7.8.5",
|
||||
"smol-toml": "^1.7.0",
|
||||
@@ -172,8 +178,8 @@
|
||||
"lint-staged": "16.4.0",
|
||||
"oxlint": "1.73.0",
|
||||
"prettier": "3.6.2",
|
||||
"typedoc": "0.28.20",
|
||||
"turbo": "2.10.5",
|
||||
"typedoc": "0.28.20",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.1.10"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { browserScenarioSchema } from "../../dist/domain/browserScenario.js";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
|
||||
const duplicateOriginStorage = (origin) => ({
|
||||
local_storage: [
|
||||
{
|
||||
origin,
|
||||
entries: [
|
||||
{
|
||||
name: "rea-first-seed",
|
||||
value: {
|
||||
source: "literal",
|
||||
value: "first",
|
||||
classification: "public",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
origin,
|
||||
entries: [
|
||||
{
|
||||
name: "rea-second-seed",
|
||||
value: {
|
||||
source: "literal",
|
||||
value: "second",
|
||||
classification: "public",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
/** Build the source-owned full-capture browser scenario. */
|
||||
export function browserScenario(browser, origin) {
|
||||
return browserScenarioSchema.parse({
|
||||
schema_version: 1,
|
||||
browser,
|
||||
start_url: { url: `${origin}/app`, query: [] },
|
||||
allowed_origins: [origin],
|
||||
environment: {
|
||||
viewport: { width: 1_280, height: 720, device_scale_factor: 1.25 },
|
||||
locale: "en-US",
|
||||
timezone: "UTC",
|
||||
color_scheme: "light",
|
||||
reduced_motion: "reduce",
|
||||
service_workers: "block",
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
step_id: "ready",
|
||||
action: "wait_for",
|
||||
locator: {
|
||||
kind: "role",
|
||||
role: "button",
|
||||
name: "ax-private-label-value",
|
||||
exact: true,
|
||||
},
|
||||
state: "visible",
|
||||
},
|
||||
{
|
||||
step_id: "verify",
|
||||
action: "click",
|
||||
locator: {
|
||||
kind: "role",
|
||||
role: "button",
|
||||
name: "ax-private-label-value",
|
||||
exact: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
storage: duplicateOriginStorage(origin),
|
||||
request_replay: { mode: "disabled" },
|
||||
secrets: [],
|
||||
redaction: {
|
||||
secret_values: "replace-with-secret-reference",
|
||||
query_parameter_names: ["token"],
|
||||
},
|
||||
capture: {
|
||||
after_each_step: [
|
||||
"screenshot",
|
||||
"dom",
|
||||
"accessibility",
|
||||
"url",
|
||||
"history",
|
||||
"storage",
|
||||
],
|
||||
at_end: [
|
||||
"screenshot",
|
||||
"dom",
|
||||
"accessibility",
|
||||
"url",
|
||||
"history",
|
||||
"storage",
|
||||
],
|
||||
events: [
|
||||
"console",
|
||||
"page-errors",
|
||||
"network",
|
||||
"websockets",
|
||||
"frames",
|
||||
"workers",
|
||||
"popups",
|
||||
"downloads",
|
||||
],
|
||||
},
|
||||
limits: {
|
||||
max_duration_ms: 30_000,
|
||||
action_timeout_ms: 5_000,
|
||||
navigation_timeout_ms: 10_000,
|
||||
max_events: 10_000,
|
||||
max_frames: 100,
|
||||
max_workers: 20,
|
||||
max_popups: 10,
|
||||
max_websockets: 100,
|
||||
max_dom_nodes: 10_000,
|
||||
max_accessibility_nodes: 10_000,
|
||||
max_screenshots: 8,
|
||||
max_screenshot_bytes: 4 * 1_024 * 1_024,
|
||||
max_storage_entries: 256,
|
||||
max_total_metadata_bytes: 8 * 1_024 * 1_024,
|
||||
},
|
||||
approved: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Run scenario capture through the public one-shot CLI. */
|
||||
export async function runScenarioCli(scenario, configuration) {
|
||||
const { stdout } = await execute(
|
||||
process.execPath,
|
||||
[
|
||||
"scripts/rea.mjs",
|
||||
"capture-browser-scenario",
|
||||
JSON.stringify(scenario),
|
||||
"--json",
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
REA_BROWSER_SCENARIO_ENABLED: "true",
|
||||
REA_BROWSER_SCENARIO_AUTO_GRANT: "true",
|
||||
REA_BROWSER_SCENARIO_EXECUTABLE_ROOTS_JSON: JSON.stringify([
|
||||
dirname(scenario.browser.executable_path ?? process.execPath),
|
||||
]),
|
||||
REA_BROWSER_SCENARIO_ALLOWED_ENV_JSON: "[]",
|
||||
...configuration,
|
||||
},
|
||||
maxBuffer: 64 * 1_024 * 1_024,
|
||||
},
|
||||
);
|
||||
return JSON.parse(stdout);
|
||||
}
|
||||
|
||||
/** Snapshot provider-owned profile names for cleanup comparison. */
|
||||
export async function scenarioProfiles() {
|
||||
return new Set(
|
||||
(await readdir(tmpdir())).filter((entry) =>
|
||||
entry.startsWith("rea-browser-scenario-"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Assert full real-browser scenario evidence and cleanup prerequisites. */
|
||||
export function assertScenarioCapture(capture) {
|
||||
if (
|
||||
capture.steps.length !== 3 ||
|
||||
capture.steps.some(({ status }) => status !== "completed")
|
||||
)
|
||||
throw new Error("Scenario launch did not complete every declared step");
|
||||
const final = capture.steps.at(-1);
|
||||
if (
|
||||
final === undefined ||
|
||||
Object.values(final.artifacts).some(({ state }) => state !== "captured")
|
||||
)
|
||||
throw new Error(
|
||||
`Scenario launch did not capture every requested artifact: ${JSON.stringify(final?.artifacts ?? null)}`,
|
||||
);
|
||||
const storageNames =
|
||||
final.artifacts.storage.state === "captured"
|
||||
? final.artifacts.storage.value.local_storage.map(({ name }) => name)
|
||||
: [];
|
||||
if (
|
||||
!storageNames.includes("rea-first-seed") ||
|
||||
!storageNames.includes("rea-second-seed")
|
||||
)
|
||||
throw new Error("Scenario launch did not retain duplicate-origin seeds");
|
||||
if (
|
||||
capture.events.items.length === 0 ||
|
||||
capture.completeness.equality_eligible !== true
|
||||
)
|
||||
throw new Error(
|
||||
"Scenario launch did not produce complete runtime evidence",
|
||||
);
|
||||
}
|
||||
@@ -16,3 +16,167 @@ export function sourceMapSummary(sourceMaps) {
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Assert passive real-browser inventory, event, privacy, and metadata claims. */
|
||||
export function assertObservation(result, origin) {
|
||||
assertObservationInventory(result, origin);
|
||||
assertObservationEvents(result);
|
||||
assertObservationPrivacy(result);
|
||||
assertObservationMetadata(result);
|
||||
}
|
||||
|
||||
function assertObservationInventory(result, origin) {
|
||||
if (result.target.origin !== origin)
|
||||
throw new Error("Real Chrome target origin was not preserved exactly");
|
||||
if (result.frames.length < 1 || result.dom.nodes.length < 1)
|
||||
throw new Error(
|
||||
`Real Chrome DOM/frame observation was empty: ${JSON.stringify({ frames: result.frames.length, domTotal: result.dom.total_nodes, domReturned: result.dom.nodes.length, limitations: result.limitations })}`,
|
||||
);
|
||||
if (result.accessibility.nodes.length < 1)
|
||||
throw new Error("Real Chrome accessibility observation was empty");
|
||||
if (
|
||||
result.accessibility.text_capture.status !== "not_approved" ||
|
||||
result.accessibility.nodes.some(
|
||||
(node) => node.name !== null || node.description !== null,
|
||||
)
|
||||
)
|
||||
throw new Error("Accessibility text was retained without approval");
|
||||
if (!result.scripts.items.some((script) => script.url.includes("/app.js")))
|
||||
throw new Error("Real Chrome script metadata was missing");
|
||||
if (!result.resources.some((resource) => resource.url.includes("/app.js")))
|
||||
throw new Error("Real Chrome resource metadata was missing");
|
||||
}
|
||||
|
||||
function assertObservationEvents(result) {
|
||||
if (!result.network.requests.some((request) => request.url.includes("/api")))
|
||||
throw new Error(
|
||||
"Real Chrome attach-window network observation was missing",
|
||||
);
|
||||
const initiatedRequest = result.network.requests.find((request) =>
|
||||
request.url.includes("/api"),
|
||||
);
|
||||
if (
|
||||
!initiatedRequest?.initiator.url?.includes("/app.js") ||
|
||||
initiatedRequest.initiator.line === null ||
|
||||
initiatedRequest.initiator.column === null
|
||||
)
|
||||
throw new Error("Real Chrome script initiator stack was not normalized");
|
||||
if (result.console.events.length < 1)
|
||||
throw new Error(
|
||||
"Real Chrome attach-window console observation was missing",
|
||||
);
|
||||
if (result.network.websocket_events.length < 1)
|
||||
throw new Error("Real Chrome WebSocket metadata was missing");
|
||||
}
|
||||
|
||||
function assertObservationPrivacy(result) {
|
||||
if (
|
||||
result.console.events.some(
|
||||
(event) => event.text_capture.status !== "not_approved",
|
||||
) ||
|
||||
result.network.requests.some(
|
||||
(request) => request.body_shapes.status !== "not_approved",
|
||||
) ||
|
||||
result.network.websocket_events.some(
|
||||
(event) => event.payload_shape !== null,
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
"Sensitive text or payload shapes were retained without approval",
|
||||
);
|
||||
}
|
||||
|
||||
function assertObservationMetadata(result) {
|
||||
if (!result.storage.local_storage_keys.includes("rea-storage-key"))
|
||||
throw new Error("Real Chrome local-storage key inventory was missing");
|
||||
if (!result.storage.indexed_db_names.includes("rea-browser-db"))
|
||||
throw new Error("Real Chrome IndexedDB name inventory was missing");
|
||||
if (!result.storage.cache_names.includes("rea-browser-cache"))
|
||||
throw new Error("Real Chrome cache name inventory was missing");
|
||||
if (
|
||||
!result.metadata.responses.some(({ csp }) =>
|
||||
csp.directives.some(({ name }) => name === "default-src"),
|
||||
) ||
|
||||
!result.metadata.agent_hints.some(
|
||||
({ declaration }) => declaration === "service-desc",
|
||||
)
|
||||
)
|
||||
throw new Error("Real Chrome safe response metadata was missing");
|
||||
}
|
||||
|
||||
/** Assert real-browser static bundle and source-map findings. */
|
||||
export function assertBundleAnalysis(result) {
|
||||
if (
|
||||
result.capture.scripts_analyzed < 1 ||
|
||||
!result.observations.routes.some(({ value }) =>
|
||||
value.includes("/verified-route"),
|
||||
) ||
|
||||
!result.observations.endpoints.some(({ value }) => value.includes("/api"))
|
||||
)
|
||||
throw new Error("Real Chrome static bundle findings were missing");
|
||||
if (
|
||||
result.observations.source_maps.status !== "included" ||
|
||||
!result.observations.source_maps.items.some(
|
||||
({ status, original_sources, original_module_edges, mappings }) =>
|
||||
status === "included" &&
|
||||
original_sources.some(({ source }) =>
|
||||
source.includes("/src/main.ts"),
|
||||
) &&
|
||||
original_module_edges.some(
|
||||
({ specifier }) => specifier === "./dependency.ts",
|
||||
) &&
|
||||
mappings.length > 0,
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
`Real Chrome approved source-map reconstruction was missing: ${JSON.stringify(sourceMapSummary(result.observations.source_maps))}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Assert explicitly approved console, JSON, and WebSocket shape capture. */
|
||||
export function assertSensitiveShapes(result) {
|
||||
const request = result.network.requests.find(
|
||||
(item) =>
|
||||
item.url.includes("/api") &&
|
||||
item.body_shapes.request?.properties.some(
|
||||
({ path }) => path === "/token",
|
||||
) &&
|
||||
item.body_shapes.response?.properties.some(
|
||||
({ path }) => path === "/secret",
|
||||
),
|
||||
);
|
||||
if (request === undefined)
|
||||
throw new Error("Real Chrome JSON request/response shapes were missing");
|
||||
const consoleText = result.console.events.flatMap(
|
||||
(event) => event.text_capture.values,
|
||||
);
|
||||
if (
|
||||
!consoleText.some(({ text }) => text.includes("[REDACTED]")) ||
|
||||
consoleText.some(({ text }) => text.includes("console-secret-value"))
|
||||
)
|
||||
throw new Error(
|
||||
"Real Chrome approved console text was not safely redacted",
|
||||
);
|
||||
if (
|
||||
!result.network.websocket_events.some(
|
||||
(event) =>
|
||||
event.payload_shape?.format === "json" &&
|
||||
event.payload_shape.json_shape?.properties.some(
|
||||
({ path }) => path === "/token",
|
||||
),
|
||||
)
|
||||
)
|
||||
throw new Error("Real Chrome WebSocket JSON shape was missing");
|
||||
const serialized = JSON.stringify({
|
||||
console: result.console,
|
||||
network: result.network,
|
||||
});
|
||||
for (const secret of [
|
||||
"request-body-secret-value",
|
||||
"response-secret-value",
|
||||
"websocket-secret-value",
|
||||
"console-secret-value",
|
||||
])
|
||||
if (serialized.includes(secret))
|
||||
throw new Error(`Approved shape capture retained raw value: ${secret}`);
|
||||
}
|
||||
|
||||
@@ -34,13 +34,23 @@ export const toolFamilyCatalog = (sources) => {
|
||||
{
|
||||
id: "browser",
|
||||
surface: "browser-provider",
|
||||
contracts: sources.browserContracts.BROWSER_TOOL_CONTRACTS,
|
||||
contracts: [
|
||||
...sources.browserContracts.BROWSER_TOOL_CONTRACTS,
|
||||
...sources.browserScenarioContracts.BROWSER_SCENARIO_TOOL_CONTRACTS,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "electron",
|
||||
surface: "electron-provider",
|
||||
contracts: sources.electronContracts.ELECTRON_TOOL_CONTRACTS,
|
||||
},
|
||||
{
|
||||
id: "javascript-runtime",
|
||||
surface: "runtime-provider",
|
||||
contracts:
|
||||
sources.javascriptRuntimeObservationContracts
|
||||
.JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS,
|
||||
},
|
||||
{
|
||||
id: "application",
|
||||
surface: "application-workflow",
|
||||
@@ -121,10 +131,23 @@ export const providerCatalog = (sources) => {
|
||||
identity: sources.browserProvider.CDP_BROWSER_PROVIDER_IDENTITY,
|
||||
contracts: sources.browserContracts.BROWSER_TOOL_CONTRACTS,
|
||||
},
|
||||
{
|
||||
identity:
|
||||
sources.browserScenarioProvider
|
||||
.PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY,
|
||||
contracts:
|
||||
sources.browserScenarioContracts.BROWSER_SCENARIO_TOOL_CONTRACTS,
|
||||
},
|
||||
{
|
||||
identity: sources.electronProvider.CDP_ELECTRON_PROVIDER_IDENTITY,
|
||||
contracts: observationContracts,
|
||||
},
|
||||
{
|
||||
identity: sources.v8InspectorProvider.V8_INSPECTOR_PROVIDER_IDENTITY,
|
||||
contracts:
|
||||
sources.javascriptRuntimeObservationContracts
|
||||
.JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS,
|
||||
},
|
||||
{
|
||||
identity: sources.artifactProviders.JAVASCRIPT_APPLICATION_PROVIDER,
|
||||
contracts: applicationContracts,
|
||||
|
||||
@@ -31,7 +31,10 @@ const SOURCE_PATHS = {
|
||||
managedContracts: "dist/contracts/managedToolContracts.js",
|
||||
managedWorkflowContracts: "dist/contracts/managedWorkflowToolContracts.js",
|
||||
browserContracts: "dist/contracts/browserToolContracts.js",
|
||||
browserScenarioContracts: "dist/contracts/browserScenarioToolContracts.js",
|
||||
electronContracts: "dist/contracts/electronToolContracts.js",
|
||||
javascriptRuntimeObservationContracts:
|
||||
"dist/contracts/javascriptRuntimeObservationToolContracts.js",
|
||||
applicationContracts: "dist/contracts/applicationToolContracts.js",
|
||||
supportedClients: "dist/application/SupportedClients.js",
|
||||
hopperProvider: "dist/hopper/HopperProvider.js",
|
||||
@@ -39,7 +42,9 @@ const SOURCE_PATHS = {
|
||||
nativeProvider: "dist/native/NativeMacOSProvider.js",
|
||||
artifactProviders: "dist/application/InvestigationProviders.js",
|
||||
browserProvider: "dist/browser/CdpBrowserProvider.js",
|
||||
browserScenarioProvider: "dist/browser/PlaywrightBrowserScenarioProvider.js",
|
||||
electronProvider: "dist/browser/CdpElectronProvider.js",
|
||||
v8InspectorProvider: "dist/browser/V8InspectorProvider.js",
|
||||
evidence: "dist/domain/evidence.js",
|
||||
evidenceBundle: "dist/domain/evidenceBundle.js",
|
||||
evidenceCompletion: "dist/domain/evidenceCompletionLedger.js",
|
||||
@@ -49,8 +54,12 @@ const SOURCE_PATHS = {
|
||||
artifactGraph: "dist/domain/artifactGraph.js",
|
||||
investigationWorkspace: "dist/domain/investigationWorkspace.js",
|
||||
browserObservation: "dist/domain/browserObservation.js",
|
||||
browserScenario: "dist/domain/browserScenario.js",
|
||||
browserScenarioCapture: "dist/domain/browserScenarioCapture.js",
|
||||
browserScenarioDiff: "dist/domain/browserScenarioDiff.js",
|
||||
browserSession: "dist/domain/browserSession.js",
|
||||
electronObservation: "dist/domain/electronObservation.js",
|
||||
javascriptRuntimeObservation: "dist/domain/javascriptRuntimeObservation.js",
|
||||
webBundleAnalysis: "dist/domain/webBundleAnalysis.js",
|
||||
webCaptureDiff: "dist/domain/webCaptureDiff.js",
|
||||
managedArtifact: "dist/domain/managedArtifact.js",
|
||||
|
||||
@@ -23,7 +23,11 @@ const valuesAt = (node, path) => {
|
||||
|
||||
const versionAt = (schema, path) => {
|
||||
const versions = [
|
||||
...new Set(valuesAt(z.toJSONSchema(schema), path).map(String)),
|
||||
...new Set(
|
||||
valuesAt(z.toJSONSchema(schema, { unrepresentable: "any" }), path).map(
|
||||
String,
|
||||
),
|
||||
),
|
||||
];
|
||||
if (versions.length !== 1)
|
||||
throw new Error(
|
||||
@@ -50,6 +54,11 @@ const durableSchemaDefinitions = (sources) => [
|
||||
sources.artifactGraph.artifactExtractionResultSchema,
|
||||
["extraction_manifest", "schema_version"],
|
||||
],
|
||||
[
|
||||
"browser_scenario",
|
||||
sources.browserScenario.browserScenarioSchema,
|
||||
["schema_version"],
|
||||
],
|
||||
["evidence", sources.evidence.evidenceSchema, ["schema_version"]],
|
||||
[
|
||||
"evidence_bundle",
|
||||
@@ -129,6 +138,19 @@ const durableSchemaDefinitions = (sources) => [
|
||||
],
|
||||
];
|
||||
|
||||
const browserScenarioObservationSchemaDefinitions = (sources) => [
|
||||
[
|
||||
"browser_scenario_capture",
|
||||
sources.browserScenarioCapture.browserScenarioCaptureSchema,
|
||||
["schema_version"],
|
||||
],
|
||||
[
|
||||
"browser_scenario_diff",
|
||||
sources.browserScenarioDiff.browserScenarioDiffSchema,
|
||||
["schema_version"],
|
||||
],
|
||||
];
|
||||
|
||||
const observationSchemaDefinitions = (sources) => [
|
||||
[
|
||||
"managed_artifact_inspection",
|
||||
@@ -160,6 +182,7 @@ const observationSchemaDefinitions = (sources) => [
|
||||
sources.browserObservation.browserTargetListSchema,
|
||||
["schema_version"],
|
||||
],
|
||||
...browserScenarioObservationSchemaDefinitions(sources),
|
||||
[
|
||||
"electron_page_inspection",
|
||||
sources.electronObservation.electronPageInspectionSchema,
|
||||
@@ -176,6 +199,16 @@ const observationSchemaDefinitions = (sources) => [
|
||||
.javaScriptApplicationAnalysisResultV2Schema,
|
||||
["schema_version"],
|
||||
],
|
||||
[
|
||||
"javascript_runtime_observation",
|
||||
sources.javascriptRuntimeObservation.javascriptRuntimeObservationSchema,
|
||||
["schema_version"],
|
||||
],
|
||||
[
|
||||
"javascript_runtime_target_list",
|
||||
sources.javascriptRuntimeObservation.javascriptRuntimeTargetListSchema,
|
||||
["schema_version"],
|
||||
],
|
||||
[
|
||||
"web_bundle_analysis",
|
||||
sources.webBundleAnalysis.webBundleAnalysisSchema,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { requireMcpResult } from "./mcp-verifier-results.mjs";
|
||||
|
||||
export const verifyHopperFunctionBasics = async (
|
||||
client,
|
||||
options,
|
||||
procedure,
|
||||
) => {
|
||||
const containment = requireMcpResult(
|
||||
await client.callTool(
|
||||
{
|
||||
name: "resolve_containing_procedure",
|
||||
arguments: { address: procedure },
|
||||
},
|
||||
options,
|
||||
),
|
||||
"resolve_containing_procedure",
|
||||
);
|
||||
if (
|
||||
containment?.found !== true ||
|
||||
containment.procedure?.address !== procedure
|
||||
) {
|
||||
throw new Error(
|
||||
"resolve_containing_procedure returned the wrong procedure",
|
||||
);
|
||||
}
|
||||
|
||||
const references = requireMcpResult(
|
||||
await client.callTool(
|
||||
{
|
||||
name: "procedure_references",
|
||||
arguments: {
|
||||
procedure,
|
||||
direction: "outgoing",
|
||||
limit: 10,
|
||||
max_instructions: 100,
|
||||
},
|
||||
},
|
||||
options,
|
||||
),
|
||||
"procedure_references",
|
||||
);
|
||||
if (!Array.isArray(references?.references?.items)) {
|
||||
throw new Error("procedure_references returned an invalid bounded result");
|
||||
}
|
||||
|
||||
const instructions = requireMcpResult(
|
||||
await client.callTool(
|
||||
{
|
||||
name: "read_function_instructions",
|
||||
arguments: { procedure, limit: 2 },
|
||||
},
|
||||
options,
|
||||
),
|
||||
"read_function_instructions",
|
||||
);
|
||||
if (
|
||||
instructions?.procedure?.address !== procedure ||
|
||||
!Array.isArray(instructions.instructions?.items) ||
|
||||
instructions.instructions.items.length === 0 ||
|
||||
instructions.instructions.items.length > 2
|
||||
) {
|
||||
throw new Error(
|
||||
"read_function_instructions returned an invalid bounded window",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
outgoingReferenceCount: references.references.items.length,
|
||||
instructionWindowCount: instructions.instructions.items.length,
|
||||
};
|
||||
};
|
||||
@@ -28,6 +28,28 @@ export const runWithStatus = async (command, args, env) => {
|
||||
/** Parse JSON from a command output string. */
|
||||
export const json = (text) => JSON.parse(text);
|
||||
|
||||
/** Verify that the advertised MCP catalog matches the session's available tools. */
|
||||
export const verifyAvailableToolCatalog = async (client, options) => {
|
||||
const listed = await client.listTools(undefined, options);
|
||||
const status = await client.callTool(
|
||||
{ name: "binary_session", arguments: { detail: "full" } },
|
||||
options,
|
||||
);
|
||||
const availability = status.structuredContent?.result?.tool_availability;
|
||||
if (!Array.isArray(availability))
|
||||
throw new Error("packaged MCP omitted tool availability");
|
||||
const expected = availability
|
||||
.filter(({ available }) => available === true)
|
||||
.map(({ name }) => name)
|
||||
.sort();
|
||||
const observed = listed.tools.map(({ name }) => name).sort();
|
||||
if (JSON.stringify(observed) !== JSON.stringify(expected))
|
||||
throw new Error(
|
||||
"packaged MCP tool inventory diverged from session availability",
|
||||
);
|
||||
return observed;
|
||||
};
|
||||
|
||||
/** Check whether a path exists on disk. */
|
||||
export const pathExists = async (path) => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
export interface PackageHopperEnvironment {
|
||||
readonly HOPPER_LAUNCHER_PATH: string;
|
||||
readonly HOPPER_LOADER_ARGS_JSON: string;
|
||||
}
|
||||
|
||||
export function packageHopperEnvironment(
|
||||
root: string,
|
||||
platform?: string,
|
||||
): PackageHopperEnvironment;
|
||||
|
||||
export function verifyPackageEnvironment(input: {
|
||||
readonly root: string;
|
||||
readonly workspace: string;
|
||||
readonly evidenceRoot: string;
|
||||
readonly referenceRoot: string;
|
||||
}): Promise<{
|
||||
readonly prefix: string;
|
||||
readonly home: string;
|
||||
readonly npxLog: string;
|
||||
readonly claudeConfig: string;
|
||||
readonly codexConfig: string;
|
||||
readonly codexTarget: string;
|
||||
readonly cursorConfig: string;
|
||||
readonly cursorTarget: string;
|
||||
readonly environment: Readonly<Record<string, string | undefined>>;
|
||||
}>;
|
||||
@@ -72,10 +72,7 @@ export async function verifyPackageEnvironment({
|
||||
XDG_CONFIG_HOME: join(home, ".config"),
|
||||
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
|
||||
REA_ANALYSIS_PROVIDER: "auto",
|
||||
HOPPER_LAUNCHER_PATH: process.execPath,
|
||||
HOPPER_LOADER_ARGS_JSON: JSON.stringify([
|
||||
join(root, "tests/fixtures/fakeLauncher.mjs"),
|
||||
]),
|
||||
...packageHopperEnvironment(root),
|
||||
REA_NPX_LOG: npxLog,
|
||||
REA_EVIDENCE_ROOTS_JSON: JSON.stringify([evidenceRoot]),
|
||||
REA_INVESTIGATION_INPUT_ROOTS_JSON: JSON.stringify([workspace]),
|
||||
@@ -94,3 +91,22 @@ export async function verifyPackageEnvironment({
|
||||
environment,
|
||||
};
|
||||
}
|
||||
|
||||
export const packageHopperEnvironment = (root, platform = process.platform) => {
|
||||
const fakeLauncher = join(root, "tests/fixtures/fakeLauncher.mjs");
|
||||
if (platform !== "linux")
|
||||
return {
|
||||
HOPPER_LAUNCHER_PATH: process.execPath,
|
||||
HOPPER_LOADER_ARGS_JSON: JSON.stringify([fakeLauncher]),
|
||||
};
|
||||
return {
|
||||
HOPPER_LAUNCHER_PATH: "/bin/sh",
|
||||
HOPPER_LOADER_ARGS_JSON: JSON.stringify([
|
||||
"-c",
|
||||
'node_path=$1; shift; "$node_path" "$@"',
|
||||
"rea-package-hopper",
|
||||
process.execPath,
|
||||
fakeLauncher,
|
||||
]),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -5,18 +5,16 @@ import { promisify } from "node:util";
|
||||
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js";
|
||||
import * as prompts from "./verify-package-prompts.mjs";
|
||||
import { json } from "./lib/verify-package-core.mjs";
|
||||
import {
|
||||
json,
|
||||
verifyAvailableToolCatalog,
|
||||
} from "./lib/verify-package-core.mjs";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
|
||||
const verifyMcpToolsAndPrompts = async (client, mcpOptions) => {
|
||||
if (
|
||||
(await client.listTools(undefined, mcpOptions)).tools.length !==
|
||||
TOOL_CONTRACTS.length
|
||||
)
|
||||
throw new Error("packaged MCP tool inventory diverged from contracts");
|
||||
await verifyAvailableToolCatalog(client, mcpOptions);
|
||||
await prompts.verifyPromptCatalog(client, mcpOptions, prompts.names);
|
||||
await prompts.verifyPromptCompletion(client, mcpOptions, false);
|
||||
};
|
||||
@@ -44,12 +42,11 @@ const verifyMcpReplay = async (client, mcpOptions, investigationReplay) => {
|
||||
};
|
||||
|
||||
const verifyMcpTargetFree = async (client, mcpOptions) => {
|
||||
const result = await client.callTool(
|
||||
{ name: "current_document", arguments: {} },
|
||||
mcpOptions,
|
||||
const names = (await client.listTools(undefined, mcpOptions)).tools.map(
|
||||
({ name }) => name,
|
||||
);
|
||||
if (result.isError !== true)
|
||||
throw new Error("packaged target-free MCP omitted no-target error");
|
||||
if (names.includes("current_document"))
|
||||
throw new Error("packaged target-free MCP advertised a target-bound tool");
|
||||
};
|
||||
|
||||
const verifyMcpUnknownProvider = async (client, mcpOptions) => {
|
||||
@@ -97,14 +94,12 @@ const verifyMcpOpenAndBind = async (client, mcpOptions) => {
|
||||
throw new Error("packaged MCP omitted its explicit Hopper binding");
|
||||
};
|
||||
|
||||
const verifyMcpLinuxCurrentDocument = async (client, mcpOptions, current) => {
|
||||
if (
|
||||
current.isError !== true ||
|
||||
!prompts
|
||||
.mcpText(current)
|
||||
.includes("not supported for unattended Linux analysis")
|
||||
)
|
||||
throw new Error("packaged Linux MCP did not reject an unpinned Hopper");
|
||||
const verifyMcpLinuxToolAvailability = async (client, mcpOptions) => {
|
||||
const names = (await client.listTools(undefined, mcpOptions)).tools.map(
|
||||
({ name }) => name,
|
||||
);
|
||||
if (names.includes("current_document"))
|
||||
throw new Error("packaged Linux MCP advertised unavailable Hopper tools");
|
||||
};
|
||||
|
||||
const verifyMcpNonLinuxCurrentDocument = async (
|
||||
@@ -140,13 +135,13 @@ const verifyMcpBinaryLifecycle = async (client, mcpOptions) => {
|
||||
mcpOptions,
|
||||
process.platform !== "linux",
|
||||
);
|
||||
const current = await client.callTool(
|
||||
{ name: "current_document", arguments: {} },
|
||||
mcpOptions,
|
||||
);
|
||||
if (process.platform === "linux") {
|
||||
await verifyMcpLinuxCurrentDocument(client, mcpOptions, current);
|
||||
await verifyMcpLinuxToolAvailability(client, mcpOptions);
|
||||
} else {
|
||||
const current = await client.callTool(
|
||||
{ name: "current_document", arguments: {} },
|
||||
mcpOptions,
|
||||
);
|
||||
await verifyMcpNonLinuxCurrentDocument(client, mcpOptions, current);
|
||||
}
|
||||
const closed = await client.callTool(
|
||||
@@ -191,7 +186,8 @@ export async function verifyPackageMcp({
|
||||
);
|
||||
if (
|
||||
diagnosed.healthy !== true ||
|
||||
diagnosed.inventory?.tools?.observed !== TOOL_CONTRACTS.length
|
||||
diagnosed.inventory?.tools?.observed !==
|
||||
diagnosed.inventory?.tools?.expected
|
||||
)
|
||||
throw new Error("packaged production MCP doctor failed");
|
||||
const transport = new StdioClientTransport({
|
||||
|
||||
+78
-162
@@ -7,6 +7,7 @@ import { join } from "node:path";
|
||||
|
||||
import { CdpBrowserProvider } from "../dist/browser/CdpBrowserProvider.js";
|
||||
import { waitForBrowserDevtoolsPort } from "../dist/browser/BrowserProcessStartup.js";
|
||||
import { PlaywrightBrowserScenarioProvider } from "../dist/browser/PlaywrightBrowserScenarioProvider.js";
|
||||
import {
|
||||
inspectWebPageInputSchema,
|
||||
listBrowserTargetsInputSchema,
|
||||
@@ -20,7 +21,17 @@ import {
|
||||
} from "../dist/domain/webScreenshot.js";
|
||||
import { startBrowserVerifierSite } from "./fixtures/browser-verifier-site.mjs";
|
||||
import { startPageCdpProxy } from "./fixtures/page-cdp-proxy.mjs";
|
||||
import { sourceMapSummary } from "./lib/browser-verifier-assertions.mjs";
|
||||
import {
|
||||
assertBundleAnalysis,
|
||||
assertObservation,
|
||||
assertSensitiveShapes,
|
||||
} from "./lib/browser-verifier-assertions.mjs";
|
||||
import {
|
||||
assertScenarioCapture,
|
||||
browserScenario,
|
||||
runScenarioCli,
|
||||
scenarioProfiles,
|
||||
} from "./lib/browser-scenario-verifier.mjs";
|
||||
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
|
||||
|
||||
const SECRET_VALUES = [
|
||||
@@ -30,6 +41,7 @@ const SECRET_VALUES = [
|
||||
"websocket-secret-value",
|
||||
"ax-private-label-value",
|
||||
];
|
||||
const REAL_BROWSER_STARTUP_TIMEOUT_MS = 30_000;
|
||||
const verifierRun = createVerifierRun();
|
||||
|
||||
const executable = await browserExecutable();
|
||||
@@ -68,6 +80,7 @@ try {
|
||||
executable,
|
||||
activePortPath: join(profile, "DevToolsActivePort"),
|
||||
stderr: () => stderr,
|
||||
timeoutMs: REAL_BROWSER_STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
const endpoint = `http://127.0.0.1:${String(port)}`;
|
||||
const provider = new CdpBrowserProvider();
|
||||
@@ -80,6 +93,7 @@ try {
|
||||
target_id: target,
|
||||
observation_ms: 1_000,
|
||||
include_storage_keys: true,
|
||||
include_storage_fingerprints: true,
|
||||
}),
|
||||
);
|
||||
if (!observed.ok) throw observed.error;
|
||||
@@ -208,6 +222,66 @@ try {
|
||||
|
||||
pageProxy = await startPageCdpProxy(endpoint);
|
||||
await verifyPageScopedTransport(provider, pageProxy, site.origin);
|
||||
const attachedScenario = await runScenarioCli(
|
||||
browserScenario(
|
||||
{
|
||||
mode: "connect",
|
||||
cdp_endpoint: endpoint,
|
||||
target_id: target,
|
||||
ownership: "external",
|
||||
cleanup: "disconnect-only",
|
||||
},
|
||||
site.origin,
|
||||
),
|
||||
{
|
||||
REA_BROWSER_SCENARIO_CDP_ENDPOINTS_JSON: JSON.stringify([endpoint]),
|
||||
REA_BROWSER_SCENARIO_ALLOWED_ORIGINS_JSON: JSON.stringify([site.origin]),
|
||||
},
|
||||
);
|
||||
if (
|
||||
attachedScenario.normalized_result?.browser?.cleanup !==
|
||||
"disconnected-external" ||
|
||||
attachedScenario.normalized_result?.browser?.process_ownership !==
|
||||
"external"
|
||||
)
|
||||
throw new Error(
|
||||
"Scenario CLI did not report external disconnect ownership",
|
||||
);
|
||||
const versionResponse = await fetch(`${endpoint}/json/version`);
|
||||
if (!versionResponse.ok || browser.exitCode !== null)
|
||||
throw new Error("Scenario attachment terminated its external browser");
|
||||
|
||||
const profilesBefore = await scenarioProfiles();
|
||||
const launchedScenario =
|
||||
await new PlaywrightBrowserScenarioProvider().captureScenario(
|
||||
browserScenario(
|
||||
{
|
||||
mode: "launch",
|
||||
executable_path: executable,
|
||||
headless: true,
|
||||
user_data: "temporary-owned",
|
||||
cleanup: "close-and-delete-profile",
|
||||
},
|
||||
site.origin,
|
||||
),
|
||||
);
|
||||
if (!launchedScenario.ok) throw launchedScenario.error;
|
||||
if (
|
||||
launchedScenario.value.browser.cleanup !== "terminated-owned-process" ||
|
||||
launchedScenario.value.browser.process_ownership !== "provider-owned"
|
||||
)
|
||||
throw new Error("Scenario launch did not report owned-process cleanup");
|
||||
const profilesAfter = await scenarioProfiles();
|
||||
if ([...profilesAfter].some((entry) => !profilesBefore.has(entry)))
|
||||
throw new Error("Scenario launch retained a temporary browser profile");
|
||||
assertScenarioCapture(launchedScenario.value);
|
||||
for (const secret of ["network-secret-value", "websocket-url-secret"])
|
||||
if (
|
||||
JSON.stringify([attachedScenario, launchedScenario.value]).includes(
|
||||
secret,
|
||||
)
|
||||
)
|
||||
throw new Error(`Browser scenario retained sensitive value: ${secret}`);
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
@@ -226,6 +300,9 @@ try {
|
||||
sessionEvents: session.value.timeline.length,
|
||||
pageScopedTransport: true,
|
||||
screenshotBytes: screenshot.value.artifact.bytes,
|
||||
browserScenarioCli: true,
|
||||
browserScenarioAttachCleanup: "disconnected-external",
|
||||
browserScenarioLaunchCleanup: "terminated-owned-process",
|
||||
verified: true,
|
||||
})}\n`,
|
||||
);
|
||||
@@ -337,167 +414,6 @@ async function pageTarget(provider, endpoint, origin) {
|
||||
throw new Error("Real Chrome did not expose the local test page target");
|
||||
}
|
||||
|
||||
function assertObservation(result, origin) {
|
||||
assertObservationInventory(result, origin);
|
||||
assertObservationEvents(result);
|
||||
assertObservationPrivacy(result);
|
||||
assertObservationMetadata(result);
|
||||
}
|
||||
|
||||
function assertObservationInventory(result, origin) {
|
||||
if (result.target.origin !== origin)
|
||||
throw new Error("Real Chrome target origin was not preserved exactly");
|
||||
if (result.frames.length < 1 || result.dom.nodes.length < 1)
|
||||
throw new Error(
|
||||
`Real Chrome DOM/frame observation was empty: ${JSON.stringify({ frames: result.frames.length, domTotal: result.dom.total_nodes, domReturned: result.dom.nodes.length, limitations: result.limitations })}`,
|
||||
);
|
||||
if (result.accessibility.nodes.length < 1)
|
||||
throw new Error("Real Chrome accessibility observation was empty");
|
||||
if (
|
||||
result.accessibility.text_capture.status !== "not_approved" ||
|
||||
result.accessibility.nodes.some(
|
||||
(node) => node.name !== null || node.description !== null,
|
||||
)
|
||||
)
|
||||
throw new Error("Accessibility text was retained without approval");
|
||||
if (!result.scripts.items.some((script) => script.url.includes("/app.js")))
|
||||
throw new Error("Real Chrome script metadata was missing");
|
||||
if (!result.resources.some((resource) => resource.url.includes("/app.js")))
|
||||
throw new Error("Real Chrome resource metadata was missing");
|
||||
}
|
||||
|
||||
function assertObservationEvents(result) {
|
||||
if (!result.network.requests.some((request) => request.url.includes("/api")))
|
||||
throw new Error(
|
||||
"Real Chrome attach-window network observation was missing",
|
||||
);
|
||||
const initiatedRequest = result.network.requests.find((request) =>
|
||||
request.url.includes("/api"),
|
||||
);
|
||||
if (
|
||||
!initiatedRequest?.initiator.url?.includes("/app.js") ||
|
||||
initiatedRequest.initiator.line === null ||
|
||||
initiatedRequest.initiator.column === null
|
||||
)
|
||||
throw new Error("Real Chrome script initiator stack was not normalized");
|
||||
if (result.console.events.length < 1)
|
||||
throw new Error(
|
||||
"Real Chrome attach-window console observation was missing",
|
||||
);
|
||||
if (result.network.websocket_events.length < 1)
|
||||
throw new Error("Real Chrome WebSocket metadata was missing");
|
||||
}
|
||||
|
||||
function assertObservationPrivacy(result) {
|
||||
if (
|
||||
result.console.events.some(
|
||||
(event) => event.text_capture.status !== "not_approved",
|
||||
) ||
|
||||
result.network.requests.some(
|
||||
(request) => request.body_shapes.status !== "not_approved",
|
||||
) ||
|
||||
result.network.websocket_events.some(
|
||||
(event) => event.payload_shape !== null,
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
"Sensitive text or payload shapes were retained without approval",
|
||||
);
|
||||
}
|
||||
|
||||
function assertObservationMetadata(result) {
|
||||
if (!result.storage.local_storage_keys.includes("rea-storage-key"))
|
||||
throw new Error("Real Chrome local-storage key inventory was missing");
|
||||
if (!result.storage.indexed_db_names.includes("rea-browser-db"))
|
||||
throw new Error("Real Chrome IndexedDB name inventory was missing");
|
||||
if (!result.storage.cache_names.includes("rea-browser-cache"))
|
||||
throw new Error("Real Chrome cache name inventory was missing");
|
||||
if (
|
||||
!result.metadata.responses.some(({ csp }) =>
|
||||
csp.directives.some(({ name }) => name === "default-src"),
|
||||
) ||
|
||||
!result.metadata.agent_hints.some(
|
||||
({ declaration }) => declaration === "service-desc",
|
||||
)
|
||||
)
|
||||
throw new Error("Real Chrome safe response metadata was missing");
|
||||
}
|
||||
|
||||
function assertBundleAnalysis(result) {
|
||||
if (
|
||||
result.capture.scripts_analyzed < 1 ||
|
||||
!result.observations.routes.some(({ value }) =>
|
||||
value.includes("/verified-route"),
|
||||
) ||
|
||||
!result.observations.endpoints.some(({ value }) => value.includes("/api"))
|
||||
)
|
||||
throw new Error("Real Chrome static bundle findings were missing");
|
||||
if (
|
||||
result.observations.source_maps.status !== "included" ||
|
||||
!result.observations.source_maps.items.some(
|
||||
({ status, original_sources, original_module_edges, mappings }) =>
|
||||
status === "included" &&
|
||||
original_sources.some(({ source }) =>
|
||||
source.includes("/src/main.ts"),
|
||||
) &&
|
||||
original_module_edges.some(
|
||||
({ specifier }) => specifier === "./dependency.ts",
|
||||
) &&
|
||||
mappings.length > 0,
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
`Real Chrome approved source-map reconstruction was missing: ${JSON.stringify(sourceMapSummary(result.observations.source_maps))}`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertSensitiveShapes(result) {
|
||||
const request = result.network.requests.find(
|
||||
(item) =>
|
||||
item.url.includes("/api") &&
|
||||
item.body_shapes.request?.properties.some(
|
||||
({ path }) => path === "/token",
|
||||
) &&
|
||||
item.body_shapes.response?.properties.some(
|
||||
({ path }) => path === "/secret",
|
||||
),
|
||||
);
|
||||
if (request === undefined)
|
||||
throw new Error("Real Chrome JSON request/response shapes were missing");
|
||||
const consoleText = result.console.events.flatMap(
|
||||
(event) => event.text_capture.values,
|
||||
);
|
||||
if (
|
||||
!consoleText.some(({ text }) => text.includes("[REDACTED]")) ||
|
||||
consoleText.some(({ text }) => text.includes("console-secret-value"))
|
||||
)
|
||||
throw new Error(
|
||||
"Real Chrome approved console text was not safely redacted",
|
||||
);
|
||||
if (
|
||||
!result.network.websocket_events.some(
|
||||
(event) =>
|
||||
event.payload_shape?.format === "json" &&
|
||||
event.payload_shape.json_shape?.properties.some(
|
||||
({ path }) => path === "/token",
|
||||
),
|
||||
)
|
||||
)
|
||||
throw new Error("Real Chrome WebSocket JSON shape was missing");
|
||||
const serialized = JSON.stringify({
|
||||
console: result.console,
|
||||
network: result.network,
|
||||
});
|
||||
for (const secret of [
|
||||
"request-body-secret-value",
|
||||
"response-secret-value",
|
||||
"websocket-secret-value",
|
||||
"console-secret-value",
|
||||
])
|
||||
if (serialized.includes(secret))
|
||||
throw new Error(`Approved shape capture retained raw value: ${secret}`);
|
||||
}
|
||||
|
||||
function delay(milliseconds) {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
@@ -44,10 +44,27 @@ export async function verifyDebugFunctionOperations(
|
||||
if (entryString === undefined)
|
||||
throw new Error("Ghidra debug string function probe is unavailable");
|
||||
|
||||
const [info, assembly, pseudocode, callees, callers, outgoing, entryXrefs] =
|
||||
await collectEntryFunctionData(client, entry);
|
||||
const [
|
||||
info,
|
||||
assembly,
|
||||
instructionWindow,
|
||||
pseudocode,
|
||||
callees,
|
||||
callers,
|
||||
outgoing,
|
||||
entryXrefs,
|
||||
] = await collectEntryFunctionData(client, entry);
|
||||
assertLocalProcedureInfo(info, entry.address);
|
||||
assertProviderText(assembly, pseudocode, entry.address);
|
||||
if (
|
||||
instructionWindow.procedure?.address !== entry.address ||
|
||||
instructionWindow.instructions?.items?.length !== 1 ||
|
||||
instructionWindow.instructions.returned !== 1 ||
|
||||
instructionWindow.instructions_scanned < 1
|
||||
)
|
||||
throw new Error(
|
||||
"Ghidra read_function_instructions failed its bounded fast-path contract",
|
||||
);
|
||||
assertEntryCallGraph({ branch, indirect, main, callees, callers });
|
||||
const { xrefOwners, xrefOwnerFailures } = await resolveXrefOwners(
|
||||
client,
|
||||
@@ -83,6 +100,10 @@ export async function verifyDebugFunctionOperations(
|
||||
entry: dossierSummary(entryDossier),
|
||||
branch: dossierSummary(branchDossier),
|
||||
indirect: dossierSummary(indirectDossier),
|
||||
instruction_window: {
|
||||
returned: instructionWindow.instructions.returned,
|
||||
truncated: instructionWindow.instructions.truncated,
|
||||
},
|
||||
cancellation,
|
||||
timeout,
|
||||
concurrency: concurrent,
|
||||
@@ -99,6 +120,12 @@ async function collectEntryFunctionData(client, entry) {
|
||||
document: null,
|
||||
procedure: entry.value,
|
||||
}),
|
||||
functionCall(client, "read_function_instructions", {
|
||||
document: null,
|
||||
procedure: entry.value,
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
}),
|
||||
functionCall(client, "procedure_pseudo_code", {
|
||||
document: null,
|
||||
procedure: entry.value,
|
||||
|
||||
@@ -146,6 +146,7 @@ try {
|
||||
"procedure_callers",
|
||||
"procedure_info",
|
||||
"procedure_pseudo_code",
|
||||
"read_function_instructions",
|
||||
"procedure_references",
|
||||
])
|
||||
await functionOperation(operation, { procedure: procedure.value });
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
requireMcpResult,
|
||||
requireWorkflowEvidenceProvider,
|
||||
} from "./lib/mcp-verifier-results.mjs";
|
||||
import { verifyHopperFunctionBasics } from "./lib/real-hopper-function-basics.mjs";
|
||||
import { openAndVerifyLargeFixture } from "./lib/real-hopper-pagination.mjs";
|
||||
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
|
||||
import { startUnrelatedHopperSentinel } from "./lib/unrelated-hopper-sentinel.mjs";
|
||||
@@ -149,48 +150,58 @@ const verifyRelationships = async (client, options, procedure) => {
|
||||
return related;
|
||||
};
|
||||
|
||||
const verifyByteAccess = async (client, options, firstAddress) => {
|
||||
const byteRead = requireSuccessfulTool(
|
||||
await client.callTool(
|
||||
{ name: "read_bytes", arguments: { address: firstAddress, length: 16 } },
|
||||
options,
|
||||
),
|
||||
"read_bytes",
|
||||
);
|
||||
if (
|
||||
byteRead?.address !== firstAddress ||
|
||||
byteRead.requested_bytes !== 16 ||
|
||||
byteRead.returned_bytes !== 16 ||
|
||||
byteRead.complete !== true ||
|
||||
!/^[a-f0-9]{32}$/u.test(byteRead.bytes_hex)
|
||||
) {
|
||||
throw new Error("read_bytes returned an invalid bounded byte window");
|
||||
}
|
||||
const mappedOffset = requireSuccessfulTool(
|
||||
await client.callTool(
|
||||
{
|
||||
name: "address_to_file_offset",
|
||||
arguments: { address: firstAddress },
|
||||
},
|
||||
options,
|
||||
),
|
||||
"address_to_file_offset",
|
||||
);
|
||||
if (
|
||||
mappedOffset?.address !== firstAddress ||
|
||||
!Number.isSafeInteger(mappedOffset.file_offset) ||
|
||||
mappedOffset.file_offset < 0
|
||||
) {
|
||||
throw new Error("address_to_file_offset returned an invalid mapping");
|
||||
}
|
||||
return {
|
||||
byteReadCount: byteRead.returned_bytes,
|
||||
mappedFileOffset: mappedOffset.file_offset,
|
||||
};
|
||||
};
|
||||
|
||||
const verifyCurrentTarget = async (client, options) => {
|
||||
const procedures = requireSuccessfulTool(
|
||||
await client.callTool({ name: "list_procedures", arguments: {} }, options),
|
||||
"list_procedures",
|
||||
);
|
||||
const firstAddress = firstProcedureAddress(procedures);
|
||||
const containment = requireSuccessfulTool(
|
||||
await client.callTool(
|
||||
{
|
||||
name: "resolve_containing_procedure",
|
||||
arguments: { address: firstAddress },
|
||||
},
|
||||
options,
|
||||
),
|
||||
"resolve_containing_procedure",
|
||||
const byteAccess = await verifyByteAccess(client, options, firstAddress);
|
||||
const basics = await verifyHopperFunctionBasics(
|
||||
client,
|
||||
options,
|
||||
firstAddress,
|
||||
);
|
||||
if (
|
||||
containment?.found !== true ||
|
||||
containment.procedure?.address !== firstAddress
|
||||
) {
|
||||
throw new Error(
|
||||
"resolve_containing_procedure returned the wrong procedure",
|
||||
);
|
||||
}
|
||||
const references = requireSuccessfulTool(
|
||||
await client.callTool(
|
||||
{
|
||||
name: "procedure_references",
|
||||
arguments: {
|
||||
procedure: firstAddress,
|
||||
direction: "outgoing",
|
||||
limit: 10,
|
||||
max_instructions: 100,
|
||||
},
|
||||
},
|
||||
options,
|
||||
),
|
||||
"procedure_references",
|
||||
);
|
||||
if (!Array.isArray(references?.references?.items)) {
|
||||
throw new Error("procedure_references returned an invalid bounded result");
|
||||
}
|
||||
const relationships = await verifyRelationships(
|
||||
client,
|
||||
options,
|
||||
@@ -245,7 +256,9 @@ const verifyCurrentTarget = async (client, options) => {
|
||||
callerCount: relationships.procedure_callers.length,
|
||||
calleeCount: relationships.procedure_callees.length,
|
||||
xrefCount: relationships.xrefs.length,
|
||||
outgoingReferenceCount: references.references.items.length,
|
||||
outgoingReferenceCount: basics.outgoingReferenceCount,
|
||||
instructionWindowCount: basics.instructionWindowCount,
|
||||
...byteAccess,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { realpath } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
const root = resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
"../tests/conformance/runtime-inspector",
|
||||
);
|
||||
const targetPath = resolve(root, "node-target.mjs");
|
||||
const canonicalRoot = await realpath(root);
|
||||
const child = spawn(process.execPath, ["--inspect=127.0.0.1:0", targetPath], {
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
});
|
||||
|
||||
try {
|
||||
const webSocketUrl = await inspectorUrl(child);
|
||||
const socket = new URL(webSocketUrl);
|
||||
const endpoint = `http://127.0.0.1:${socket.port}`;
|
||||
const environment = {
|
||||
...process.env,
|
||||
REA_V8_INSPECTOR_OBSERVE_ENABLED: "true",
|
||||
REA_V8_INSPECTOR_ENDPOINTS_JSON: JSON.stringify([endpoint]),
|
||||
REA_V8_INSPECTOR_FILE_ROOTS_JSON: JSON.stringify([canonicalRoot]),
|
||||
REA_V8_INSPECTOR_ALLOWED_ORIGINS_JSON: "[]",
|
||||
};
|
||||
const listed = await runCli(
|
||||
["list-javascript-runtime-targets", endpoint, "--approved", "--json"],
|
||||
environment,
|
||||
);
|
||||
const target = listed.normalized_result?.targets?.items?.[0];
|
||||
if (
|
||||
listed.operation !== "list_javascript_runtime_targets" ||
|
||||
typeof target?.target_id !== "string" ||
|
||||
target.location?.kind !== "file" ||
|
||||
target.location.file_path !== targetPath
|
||||
)
|
||||
throw new Error("Real Node Inspector target discovery did not match");
|
||||
const observed = await runCli(
|
||||
[
|
||||
"observe-javascript-runtime",
|
||||
endpoint,
|
||||
target.target_id,
|
||||
"--runtime-kind",
|
||||
"node",
|
||||
"--approved",
|
||||
"--observation-ms",
|
||||
"100",
|
||||
"--json",
|
||||
],
|
||||
environment,
|
||||
);
|
||||
const result = observed.normalized_result;
|
||||
if (
|
||||
observed.operation !== "observe_javascript_runtime" ||
|
||||
observed.provider?.id !== "rea-v8-inspector" ||
|
||||
result?.target?.target_id !== target.target_id ||
|
||||
result?.target?.runtime_kind !== "node" ||
|
||||
!Array.isArray(result?.scripts?.items) ||
|
||||
result.scripts.items.length === 0
|
||||
)
|
||||
throw new Error("Real Node Inspector observation did not match");
|
||||
if (
|
||||
!Array.isArray(result.directly_observed) ||
|
||||
!Array.isArray(result.unavailable_without_instrumentation) ||
|
||||
result.capture?.truncated !== false
|
||||
)
|
||||
throw new Error("Real Node Inspector capture was incomplete");
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
schema_version: 1,
|
||||
status: "pass",
|
||||
provider: observed.provider.id,
|
||||
target_id: target.target_id,
|
||||
listed_evidence_id: listed.evidence_id,
|
||||
observation_evidence_id: observed.evidence_id,
|
||||
scripts: result.scripts.items.length,
|
||||
contexts: result.execution_contexts.length,
|
||||
endpoint,
|
||||
})}\n`,
|
||||
);
|
||||
} finally {
|
||||
await terminate(child);
|
||||
}
|
||||
|
||||
async function inspectorUrl(process_) {
|
||||
let captured = "";
|
||||
for await (const chunk of process_.stderr) {
|
||||
captured += chunk.toString("utf8");
|
||||
const match = /Debugger listening on (ws:\/\/[^\s]+)/u.exec(captured);
|
||||
if (match?.[1] !== undefined) return match[1];
|
||||
if (captured.length > 64 * 1_024)
|
||||
throw new Error("Inspector startup output exceeded its bound");
|
||||
}
|
||||
throw new Error("Node exited before reporting its Inspector URL");
|
||||
}
|
||||
|
||||
async function runCli(arguments_, environment) {
|
||||
const { stdout } = await execute(
|
||||
process.execPath,
|
||||
["scripts/rea.mjs", ...arguments_],
|
||||
{
|
||||
cwd: resolve(dirname(fileURLToPath(import.meta.url)), ".."),
|
||||
env: environment,
|
||||
maxBuffer: 16 * 1_024 * 1_024,
|
||||
},
|
||||
);
|
||||
return JSON.parse(stdout);
|
||||
}
|
||||
|
||||
async function terminate(process_) {
|
||||
if (process_.exitCode !== null) return;
|
||||
const exited = once(process_, "exit");
|
||||
process_.kill("SIGTERM");
|
||||
const terminated = await Promise.race([
|
||||
exited.then(() => true),
|
||||
new Promise((resolveTimeout) =>
|
||||
setTimeout(() => resolveTimeout(false), 2_000),
|
||||
),
|
||||
]);
|
||||
if (terminated || process_.exitCode !== null) return;
|
||||
process_.kill("SIGKILL");
|
||||
await exited;
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { access, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
|
||||
import { CATALOG_IDENTITY } from "../dist/catalogIdentity.js";
|
||||
import { RECONSTRUCTION_READINESS_EXAMPLE } from "../dist/contracts/reconstructionReadinessExample.js";
|
||||
import { parseEvidence } from "../dist/domain/evidence.js";
|
||||
import { createEvidenceBundle } from "../dist/domain/evidenceBundle.js";
|
||||
import { reconstructionReadinessReportSchema } from "../dist/domain/reconstructionReadinessSchemas.js";
|
||||
import { PACKAGE_METADATA } from "../dist/generatedPackageMetadata.js";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
const root = process.cwd();
|
||||
const nativePath = resolve(root, "build/conformance/c");
|
||||
const fixtureRoot = resolve(root, "tests/conformance/readiness");
|
||||
const javascriptPath = join(fixtureRoot, "javascript-cli");
|
||||
const electronPath = join(fixtureRoot, "electron");
|
||||
const environment = {
|
||||
...process.env,
|
||||
REA_INVESTIGATION_INPUT_ROOTS_JSON: JSON.stringify([fixtureRoot]),
|
||||
};
|
||||
const temporaryRoot = await mkdtemp(join(tmpdir(), "rea-readiness-"));
|
||||
|
||||
await access(nativePath);
|
||||
const nativeEvidence = await runCli(["inspect-artifact", nativePath, "--json"]);
|
||||
const javascriptCliEvidence = await runCli([
|
||||
"analyze-javascript-application",
|
||||
javascriptPath,
|
||||
"--approved",
|
||||
"--json",
|
||||
]);
|
||||
const electronCliEvidence = await runCli([
|
||||
"analyze-javascript-application",
|
||||
electronPath,
|
||||
"--approved",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: ["scripts/rea.mjs", "mcp"],
|
||||
cwd: root,
|
||||
env: environment,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const client = new Client({
|
||||
name: "rea-readiness-verifier",
|
||||
version: "1",
|
||||
capabilities: { elicitation: {} },
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const catalog = await client.listTools();
|
||||
const readinessTool = requireTool(
|
||||
catalog.tools,
|
||||
"evaluate_reconstruction_readiness",
|
||||
);
|
||||
const analysisTool = requireTool(
|
||||
catalog.tools,
|
||||
"analyze_javascript_application",
|
||||
);
|
||||
assertExactAstLimit(analysisTool);
|
||||
assert.equal(
|
||||
readinessTool.annotations?.readOnlyHint,
|
||||
false,
|
||||
"readiness Evidence retention side effect must stay visible",
|
||||
);
|
||||
|
||||
const javascriptMcp = await analyzeMcp(client, javascriptPath);
|
||||
const electronMcp = await analyzeMcp(client, electronPath);
|
||||
assert.equal(
|
||||
javascriptMcp.evidence_id,
|
||||
javascriptCliEvidence.evidence_id,
|
||||
"CLI/MCP JavaScript analysis Evidence identity drifted",
|
||||
);
|
||||
assert.equal(
|
||||
electronMcp.evidence_id,
|
||||
electronCliEvidence.evidence_id,
|
||||
"CLI/MCP Electron analysis Evidence identity drifted",
|
||||
);
|
||||
|
||||
const input = structuredClone(RECONSTRUCTION_READINESS_EXAMPLE);
|
||||
input.identity.cli_version = PACKAGE_METADATA.version;
|
||||
input.identity.server_version = PACKAGE_METADATA.version;
|
||||
input.identity.catalog_digest = CATALOG_IDENTITY.digests.combined_sha256;
|
||||
const records = [
|
||||
...input.evidence_bundle.records,
|
||||
nativeEvidence,
|
||||
javascriptCliEvidence,
|
||||
electronCliEvidence,
|
||||
].map(parseEvidence);
|
||||
input.evidence_bundle = createEvidenceBundle(records);
|
||||
setFixtureDigest(input, "native", nativeEvidence);
|
||||
setFixtureDigest(input, "javascript-cli", javascriptCliEvidence);
|
||||
setFixtureDigest(input, "electron", electronCliEvidence);
|
||||
linkCheck(input, "discover-classify", "artifact-inventory", nativeEvidence);
|
||||
linkCheck(input, "static-analysis", "native-routed", nativeEvidence);
|
||||
linkCheck(
|
||||
input,
|
||||
"static-analysis",
|
||||
"javascript-routed",
|
||||
javascriptCliEvidence,
|
||||
);
|
||||
linkCheck(
|
||||
input,
|
||||
"reactive-scenarios",
|
||||
"electron-correlated",
|
||||
electronCliEvidence,
|
||||
);
|
||||
|
||||
const inputPath = join(temporaryRoot, "input.json");
|
||||
await writeFile(inputPath, JSON.stringify(input));
|
||||
const cliEvidence = await runCli([
|
||||
"evaluate-reconstruction-readiness",
|
||||
inputPath,
|
||||
"--json",
|
||||
]);
|
||||
const cliReport = reconstructionReadinessReportSchema.parse(
|
||||
cliEvidence.normalized_result,
|
||||
);
|
||||
assert.equal(cliReport.status, "pass");
|
||||
if (process.env.REA_READINESS_REPORT_PATH !== undefined)
|
||||
await writeFile(
|
||||
resolve(root, process.env.REA_READINESS_REPORT_PATH),
|
||||
`${JSON.stringify(cliReport, null, 2)}\n`,
|
||||
);
|
||||
|
||||
const mcpResult = await client.callTool({
|
||||
name: "evaluate_reconstruction_readiness",
|
||||
arguments: input,
|
||||
});
|
||||
assert.notEqual(mcpResult.isError, true);
|
||||
const projection = requireObject(mcpResult.structuredContent);
|
||||
const projectedReport = requireObject(projection.result);
|
||||
assert.equal(projectedReport.report_digest, cliReport.report_digest);
|
||||
assert.equal(projectedReport.status, "pass");
|
||||
const reportUri = projectedReport.report_resource_uri;
|
||||
assert.equal(typeof reportUri, "string");
|
||||
const resource = await client.readResource({ uri: reportUri });
|
||||
const resourceContent = resource.contents[0];
|
||||
if (resourceContent === undefined || !("text" in resourceContent))
|
||||
throw new TypeError("Readiness report resource is missing JSON text");
|
||||
const retained = JSON.parse(resourceContent.text);
|
||||
assert.equal(retained.report.report_digest, cliReport.report_digest);
|
||||
|
||||
const tampered = structuredClone(input);
|
||||
tampered.replay.expected_source_digest = cliReport.source_digest;
|
||||
tampered.identity.server_version = "tampered";
|
||||
const tamperedPath = join(temporaryRoot, "tampered.json");
|
||||
await writeFile(tamperedPath, JSON.stringify(tampered));
|
||||
const tamperedEvidence = await runCli([
|
||||
"evaluate-reconstruction-readiness",
|
||||
tamperedPath,
|
||||
"--json",
|
||||
]);
|
||||
const tamperedReport = reconstructionReadinessReportSchema.parse(
|
||||
tamperedEvidence.normalized_result,
|
||||
);
|
||||
assert.equal(tamperedReport.status, "fail");
|
||||
assert.ok(
|
||||
tamperedReport.findings.some(
|
||||
({ code }) => code === "replay-source-digest-mismatch",
|
||||
),
|
||||
);
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
schema_version: 1,
|
||||
status: cliReport.status,
|
||||
report_id: cliReport.report_id,
|
||||
report_digest: cliReport.report_digest,
|
||||
stages: cliReport.summary.stages,
|
||||
findings: cliReport.summary.findings,
|
||||
native_evidence_id: nativeEvidence.evidence_id,
|
||||
javascript_evidence_id: javascriptCliEvidence.evidence_id,
|
||||
electron_evidence_id: electronCliEvidence.evidence_id,
|
||||
provider: "local-stdio",
|
||||
})}\n`,
|
||||
);
|
||||
} finally {
|
||||
await client.close();
|
||||
await rm(temporaryRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function runCli(arguments_) {
|
||||
const { stdout } = await execute(
|
||||
process.execPath,
|
||||
["scripts/rea.mjs", ...arguments_],
|
||||
{
|
||||
cwd: root,
|
||||
env: environment,
|
||||
maxBuffer: 64 * 1_024 * 1_024,
|
||||
},
|
||||
);
|
||||
return parseEvidence(JSON.parse(stdout));
|
||||
}
|
||||
|
||||
async function analyzeMcp(client_, inputPath) {
|
||||
const result = await client_.callTool({
|
||||
name: "analyze_javascript_application",
|
||||
arguments: { input_path: inputPath, approved: true },
|
||||
});
|
||||
assert.notEqual(result.isError, true);
|
||||
const projected = requireObject(result.structuredContent);
|
||||
assert.equal(typeof projected.evidence_id, "string");
|
||||
const evidence = await client_.readResource({
|
||||
uri: `rea://evidence/${projected.evidence_id}`,
|
||||
});
|
||||
const content = evidence.contents[0];
|
||||
if (content === undefined || !("text" in content))
|
||||
throw new TypeError("Analysis Evidence resource is missing JSON text");
|
||||
return parseEvidence(JSON.parse(content.text));
|
||||
}
|
||||
|
||||
function requireTool(tools, name) {
|
||||
const tool = tools.find(({ name: toolName }) => toolName === name);
|
||||
if (tool === undefined) throw new Error(`Missing public MCP tool: ${name}`);
|
||||
return tool;
|
||||
}
|
||||
|
||||
function assertExactAstLimit(tool) {
|
||||
const properties = requireObject(tool.inputSchema.properties);
|
||||
const limits = requireObject(properties.limits);
|
||||
const limitProperties = requireObject(limits.properties);
|
||||
const astLimit = requireObject(limitProperties.max_ast_nodes);
|
||||
assert.equal(astLimit.default, 2_000_000, "effective AST limit is hidden");
|
||||
assert.equal(astLimit.maximum, 20_000_000, "schema AST limit is hidden");
|
||||
}
|
||||
|
||||
function requireObject(value) {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value))
|
||||
throw new TypeError("Expected an object");
|
||||
return value;
|
||||
}
|
||||
|
||||
function setFixtureDigest(input, kind, evidence) {
|
||||
const fixture = input.fixtures.find(
|
||||
({ kind: fixtureKind }) => fixtureKind === kind,
|
||||
);
|
||||
if (fixture === undefined || evidence.subject === null)
|
||||
throw new TypeError(`Missing ${kind} fixture identity`);
|
||||
fixture.artifact_sha256 = evidence.subject.digest.sha256;
|
||||
}
|
||||
|
||||
function linkCheck(input, stageId, checkId, evidence) {
|
||||
const stage = input.stages.find(({ stage_id: id }) => id === stageId);
|
||||
const check = stage?.checks.find(({ check_id: id }) => id === checkId);
|
||||
if (stage === undefined || check === undefined)
|
||||
throw new TypeError(`Missing readiness check ${stageId}/${checkId}`);
|
||||
stage.evidence_ids = [
|
||||
...new Set([...stage.evidence_ids, evidence.evidence_id]),
|
||||
].sort();
|
||||
check.evidence_ids = [evidence.evidence_id];
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { promisify } from "node:util";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
|
||||
import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js";
|
||||
import { verifyAvailableToolCatalog } from "./lib/verify-package-core.mjs";
|
||||
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
@@ -81,19 +81,10 @@ try {
|
||||
stderr: "pipe",
|
||||
});
|
||||
const client = new Client({ name: "rea-windows-package", version: "1.0.0" });
|
||||
let toolCount = 0;
|
||||
try {
|
||||
await client.connect(server);
|
||||
const catalog = await client.listTools();
|
||||
const expected = TOOL_CONTRACTS.map(({ name }) => name).sort(
|
||||
(left, right) => left.localeCompare(right),
|
||||
);
|
||||
const actual = catalog.tools
|
||||
.map(({ name }) => name)
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected))
|
||||
throw new Error(
|
||||
"Packaged Windows MCP catalog drifted from TOOL_CONTRACTS",
|
||||
);
|
||||
toolCount = (await verifyAvailableToolCatalog(client)).length;
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
@@ -104,7 +95,7 @@ try {
|
||||
ok: true,
|
||||
platform: process.platform,
|
||||
package: packageResult.filename,
|
||||
tools: TOOL_CONTRACTS.length,
|
||||
tools: toolCount,
|
||||
ghidra_bridge: "present",
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
@@ -3,8 +3,8 @@ name: reverse-engineer-anything
|
||||
description: Reverse engineer native, managed, Electron/JavaScript, packaged, and browser applications with REA. Use shipped-artifact or approved runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis.
|
||||
metadata:
|
||||
version: "23"
|
||||
tool_count: 98
|
||||
catalog_digest: "71de8a7174176823fd82c2666130233b5a1b4a1613583270d268774775b410bc"
|
||||
tool_count: 110
|
||||
catalog_digest: "e8cac51f2f17a5b9a088c3ff773473c4897823401481fc0875a28fe7daf8eee0"
|
||||
---
|
||||
|
||||
# REA
|
||||
@@ -22,7 +22,8 @@ Choose the first tool from the target the user supplied. Do not call
|
||||
|
||||
- ASAR or extracted JavaScript/Electron tree:
|
||||
`analyze_javascript_application`.
|
||||
- Archive, application package, ZIP/APK/IPA, or DMG: `inventory_artifact`.
|
||||
- Archive, application package, ZIP/APK/IPA/MSIX/AppX, or DMG:
|
||||
`inventory_artifact`.
|
||||
- Managed PE/CLI assembly: `inspect_managed_artifact`.
|
||||
- User-owned browser page already open: `list_browser_targets`.
|
||||
- User-owned Electron runtime already open: `list_electron_targets`.
|
||||
|
||||
@@ -24,8 +24,8 @@ parser and must not become an implicit setup dependency.
|
||||
## Packages and extraction
|
||||
|
||||
Call `inventory_artifact` before extraction for application bundles, archives,
|
||||
ZIP/APK/IPA, ASAR, or DMG inputs. Continue deterministic occurrence pages from
|
||||
the returned offset only when needed. Cite graph manifest IDs.
|
||||
ZIP/APK/IPA/MSIX/AppX, ASAR, or DMG inputs. Continue deterministic occurrence
|
||||
pages from the returned offset only when needed. Cite graph manifest IDs.
|
||||
|
||||
`extract_artifact` requires explicit approval, an absent absolute output root,
|
||||
and selected occurrence IDs. Never extract every entry implicitly. Symlinks and
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type { AnalysisError } from "../domain/errors.js";
|
||||
import type { AnalysisClient, ExecutionOptions } from "./AnalysisProvider.js";
|
||||
|
||||
/** Close one provider client and normalize unexpected adapter rejection. */
|
||||
export const closeAnalysisClient = async (
|
||||
client: AnalysisClient,
|
||||
providerId: string,
|
||||
options: Pick<ExecutionOptions, "progress"> = {},
|
||||
): Promise<Result<null, AnalysisError>> => {
|
||||
try {
|
||||
if (client.closeWithOutcome !== undefined)
|
||||
return await client.closeWithOutcome(options);
|
||||
await client.close();
|
||||
return ok(null);
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new ProviderCleanupError(
|
||||
providerId,
|
||||
["provider-client"],
|
||||
{ reason: "provider client close rejected unexpectedly" },
|
||||
{ cause },
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -81,10 +81,29 @@ export interface ProviderRuntimeLineageSnapshot {
|
||||
readonly observation: ProcessLineageObservation;
|
||||
}
|
||||
|
||||
/** One provider's current serial-request activity, including detached callers. */
|
||||
export interface ProviderRequestActivitySnapshot {
|
||||
readonly provider: ProviderIdentity;
|
||||
readonly active: {
|
||||
readonly requestId: number;
|
||||
readonly operation: string;
|
||||
readonly elapsedMs: number;
|
||||
readonly timeoutMs: number;
|
||||
readonly callerState: "waiting" | "timed_out" | "cancelled";
|
||||
} | null;
|
||||
readonly queuedRequests: number;
|
||||
}
|
||||
|
||||
/** Provider session bound to exactly one parsed artifact. */
|
||||
export interface AnalysisClient extends AnalysisOperationPort {
|
||||
/** Retained token-verified process snapshots for dynamic providers this client started. */
|
||||
runtimeLineageSnapshots?(): readonly ProviderRuntimeLineageSnapshot[];
|
||||
/** Current provider work that may outlive a timed-out or cancelled caller. */
|
||||
requestActivitySnapshots?(): readonly ProviderRequestActivitySnapshot[];
|
||||
/** Close with a typed result when the provider can verify cleanup. */
|
||||
closeWithOutcome?(
|
||||
options?: Pick<ExecutionOptions, "progress">,
|
||||
): Promise<Result<null, AnalysisError>>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -127,8 +146,16 @@ interface CapabilityEffects {
|
||||
}
|
||||
|
||||
type CapabilityAvailability =
|
||||
| { readonly available: true; readonly reason: null }
|
||||
| { readonly available: false; readonly reason: string };
|
||||
| {
|
||||
readonly available: true;
|
||||
readonly reason: null;
|
||||
readonly availabilityCode?: null;
|
||||
}
|
||||
| {
|
||||
readonly available: false;
|
||||
readonly reason: string;
|
||||
readonly availabilityCode?: ProviderRejectionCode;
|
||||
};
|
||||
|
||||
interface CapabilityLimits {
|
||||
readonly maxResults: number | null;
|
||||
|
||||
@@ -3,10 +3,12 @@ import { z } from "zod";
|
||||
import {
|
||||
compareApplicationVersionsRequestSchema,
|
||||
compareJavaScriptExportShapesRequestSchema,
|
||||
compareSourceToBundleRequestSchema,
|
||||
traceApplicationFeatureRequestSchema,
|
||||
traceJavaScriptSemanticsRequestSchema,
|
||||
type CompareApplicationVersionsRequest,
|
||||
type CompareJavaScriptExportShapesRequest,
|
||||
type CompareSourceToBundleRequest,
|
||||
type TraceApplicationFeatureRequest,
|
||||
type TraceJavaScriptSemanticsRequest,
|
||||
} from "../contracts/applicationWorkflowInputContracts.js";
|
||||
@@ -16,6 +18,7 @@ import { compareApplicationVersionsInputSchema } from "../domain/javascriptAppli
|
||||
import { compareJavaScriptExportShapesInputSchema } from "../domain/javascriptExportShapeComparisonSchemas.js";
|
||||
import { traceApplicationFeatureInputSchema } from "../domain/javascriptFeatureTraceSchemas.js";
|
||||
import { traceJavaScriptSemanticsInputSchema } from "../domain/javascriptSemanticTraceSchemas.js";
|
||||
import { compareSourceToBundleInputSchema } from "../domain/sourceToBundleComparisonSchemas.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
resolveEvidenceReferences,
|
||||
@@ -48,6 +51,7 @@ type ComparisonInput = z.output<typeof compareApplicationVersionsInputSchema>;
|
||||
type ExportShapeComparisonInput = z.output<
|
||||
typeof compareJavaScriptExportShapesInputSchema
|
||||
>;
|
||||
type SourceToBundleInput = z.output<typeof compareSourceToBundleInputSchema>;
|
||||
|
||||
/** Parse and resolve one trace adapter request into its canonical domain input. */
|
||||
export const resolveTraceApplicationFeatureRequest = (
|
||||
@@ -65,16 +69,11 @@ export const resolveTraceApplicationFeatureRequestValidated = (
|
||||
input: TraceApplicationFeatureRequest,
|
||||
lookup?: EvidenceLookup,
|
||||
): Result<TraceInput, AnalysisError> => {
|
||||
const application =
|
||||
input.application === undefined
|
||||
? resolveEvidenceReferences(
|
||||
lookup,
|
||||
input.application_evidence_id === undefined
|
||||
? []
|
||||
: [input.application_evidence_id],
|
||||
APPLICATION_GRAPH_IDENTITIES,
|
||||
)
|
||||
: ok([input.application]);
|
||||
const application = graphEvidence(
|
||||
input.application,
|
||||
input.application_evidence_id,
|
||||
lookup,
|
||||
);
|
||||
if (!application.ok) return application;
|
||||
const native = resolveEvidenceReferences(
|
||||
lookup,
|
||||
@@ -82,7 +81,7 @@ export const resolveTraceApplicationFeatureRequestValidated = (
|
||||
);
|
||||
if (!native.ok) return native;
|
||||
const raw = {
|
||||
application: application.value[0],
|
||||
application: application.value,
|
||||
native_observations: [...input.native_observations, ...native.value],
|
||||
seed: input.seed,
|
||||
direction: input.direction,
|
||||
@@ -110,18 +109,13 @@ export const resolveTraceJavaScriptSemanticsRequestValidated = (
|
||||
input: TraceJavaScriptSemanticsRequest,
|
||||
lookup?: EvidenceLookup,
|
||||
): Result<SemanticTraceInput, AnalysisError> => {
|
||||
const application =
|
||||
input.application === undefined
|
||||
? resolveEvidenceReferences(
|
||||
lookup,
|
||||
input.application_evidence_id === undefined
|
||||
? []
|
||||
: [input.application_evidence_id],
|
||||
APPLICATION_GRAPH_IDENTITIES,
|
||||
)
|
||||
: ok([input.application]);
|
||||
const application = graphEvidence(
|
||||
input.application,
|
||||
input.application_evidence_id,
|
||||
lookup,
|
||||
);
|
||||
if (!application.ok) return application;
|
||||
const raw = { application: application.value[0], query: input.query };
|
||||
const raw = { application: application.value, query: input.query };
|
||||
const parsed = traceJavaScriptSemanticsInputSchema.safeParse(raw);
|
||||
return parsed.success
|
||||
? ok(parsed.data)
|
||||
@@ -180,6 +174,42 @@ export const resolveCompareApplicationVersionsRequestValidated = (
|
||||
: invalid("compare_application_versions", parsed.error, raw);
|
||||
};
|
||||
|
||||
/** Parse and resolve one historical-source comparison adapter request. */
|
||||
export const resolveCompareSourceToBundleRequest = (
|
||||
input: unknown,
|
||||
lookup?: EvidenceLookup,
|
||||
): Result<SourceToBundleInput, AnalysisError> => {
|
||||
const parsed = compareSourceToBundleRequestSchema.safeParse(input);
|
||||
return parsed.success
|
||||
? resolveCompareSourceToBundleRequestValidated(parsed.data, lookup)
|
||||
: invalid("compare_source_to_bundle", parsed.error, input);
|
||||
};
|
||||
|
||||
/** Resolve a historical-source comparison request parsed by its public contract. */
|
||||
export const resolveCompareSourceToBundleRequestValidated = (
|
||||
input: CompareSourceToBundleRequest,
|
||||
lookup?: EvidenceLookup,
|
||||
): Result<SourceToBundleInput, AnalysisError> => {
|
||||
const application = graphEvidence(
|
||||
input.application,
|
||||
input.application_evidence_id,
|
||||
lookup,
|
||||
);
|
||||
if (!application.ok) return application;
|
||||
const raw = {
|
||||
reference: input.reference,
|
||||
application: application.value,
|
||||
limits: input.limits,
|
||||
...(input.unknown_registry_approved === undefined
|
||||
? {}
|
||||
: { unknown_registry_approved: input.unknown_registry_approved }),
|
||||
};
|
||||
const parsed = compareSourceToBundleInputSchema.safeParse(raw);
|
||||
return parsed.success
|
||||
? ok(parsed.data)
|
||||
: invalid("compare_source_to_bundle", parsed.error, raw);
|
||||
};
|
||||
|
||||
/** Parse and resolve an export-shape adapter request into canonical input. */
|
||||
export const resolveCompareJavaScriptExportShapesRequest = (
|
||||
input: unknown,
|
||||
@@ -220,8 +250,11 @@ export const resolveCompareJavaScriptExportShapesRequestValidated = (
|
||||
|
||||
const graphEvidence = (
|
||||
evidence:
|
||||
| TraceApplicationFeatureRequest["application"]
|
||||
| TraceJavaScriptSemanticsRequest["application"]
|
||||
| CompareApplicationVersionsRequest["left"]
|
||||
| CompareJavaScriptExportShapesRequest["left"],
|
||||
| CompareJavaScriptExportShapesRequest["left"]
|
||||
| CompareSourceToBundleRequest["application"],
|
||||
evidenceId: string | undefined,
|
||||
lookup: EvidenceLookup | undefined,
|
||||
) => {
|
||||
|
||||
@@ -269,7 +269,13 @@ const createReader = async (
|
||||
if ((await lstat(path)).isDirectory())
|
||||
return new DirectoryArtifactReader(path);
|
||||
if (format === "asar") return new AsarArtifactReader(path);
|
||||
if (format === "ipa" || format === "apk" || format === "zip")
|
||||
if (
|
||||
format === "ipa" ||
|
||||
format === "apk" ||
|
||||
format === "msix" ||
|
||||
format === "appx" ||
|
||||
format === "zip"
|
||||
)
|
||||
return new ZipArtifactReader(path, format);
|
||||
if (format === "mach-o") return new MachOSliceArtifactReader(path);
|
||||
throw new ArtifactReaderFailure(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { extname } from "node:path";
|
||||
|
||||
import canonicalize from "canonicalize";
|
||||
|
||||
@@ -13,6 +12,7 @@ import type {
|
||||
ArtifactNode,
|
||||
ArtifactOccurrence,
|
||||
} from "../domain/artifactGraph.js";
|
||||
import { zipPackageFormatForPath } from "../domain/zipPackageFormat.js";
|
||||
|
||||
/** Mutable internal occurrence used until root-bound IDs are known. */
|
||||
export interface MutableOccurrence {
|
||||
@@ -130,7 +130,9 @@ export const createRootNode = (input: {
|
||||
size: input.digest?.bytes ?? 0,
|
||||
kind:
|
||||
input.directory ||
|
||||
["zip", "ipa", "apk", "asar", "dmg", "pkg"].includes(input.format)
|
||||
["zip", "ipa", "apk", "msix", "appx", "asar", "dmg", "pkg"].includes(
|
||||
input.format,
|
||||
)
|
||||
? "container"
|
||||
: classifyArtifactPath(input.path).kind,
|
||||
format: input.format,
|
||||
@@ -264,7 +266,7 @@ export const classifyArtifactPath = (
|
||||
if (/\.(?:m?js|cjs)$/u.test(lower))
|
||||
return { kind: "javascript", format: "javascript-bundle" };
|
||||
if (lower.endsWith(".asar")) return { kind: "container", format: "asar" };
|
||||
const archiveFormat = archiveFormatForExtension(extname(lower));
|
||||
const archiveFormat = zipPackageFormatForPath(lower);
|
||||
if (archiveFormat !== undefined)
|
||||
return { kind: "container", format: archiveFormat };
|
||||
if (/\.framework(?:\/|$)/u.test(lower))
|
||||
@@ -351,19 +353,4 @@ const relationFor = (path: string): ArtifactEdge["relation"] => {
|
||||
return "contains";
|
||||
};
|
||||
|
||||
const archiveFormatForExtension = (
|
||||
extension: string,
|
||||
): "zip" | "ipa" | "apk" | undefined => {
|
||||
switch (extension) {
|
||||
case ".zip":
|
||||
return "zip";
|
||||
case ".ipa":
|
||||
return "ipa";
|
||||
case ".apk":
|
||||
return "apk";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const depth = (path: string): number => path.split("/").length;
|
||||
|
||||
@@ -2,12 +2,15 @@ import { open } from "node:fs/promises";
|
||||
|
||||
import { classifyArtifactPath } from "../ArtifactGraphConstruction.js";
|
||||
import type { ArtifactNode } from "../../domain/artifactGraph.js";
|
||||
import { zipPackageFormatForPath } from "../../domain/zipPackageFormat.js";
|
||||
|
||||
const classifyContainerExtension = (
|
||||
path: string,
|
||||
): ArtifactNode["format"] | undefined => {
|
||||
const lower = path.toLowerCase();
|
||||
for (const format of ["asar", "ipa", "apk", "zip", "dmg", "pkg"] as const)
|
||||
const zipPackage = zipPackageFormatForPath(lower);
|
||||
if (zipPackage !== undefined) return zipPackage;
|
||||
for (const format of ["asar", "dmg", "pkg"] as const)
|
||||
if (lower.endsWith(`.${format}`)) return format;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@@ -22,6 +22,8 @@ export const createReader = async (
|
||||
case "zip":
|
||||
case "ipa":
|
||||
case "apk":
|
||||
case "msix":
|
||||
case "appx":
|
||||
return new ZipArtifactReader(path, format);
|
||||
case "asar":
|
||||
return new AsarArtifactReader(path);
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
AnalysisClient,
|
||||
AnalysisExecution,
|
||||
AnalysisClientFactory,
|
||||
ExecutionOptions,
|
||||
AnalysisProfileResolution,
|
||||
AnalysisProfileResolutionOptions,
|
||||
AnalysisOperationPort,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
type BinarySessionOpenOptions,
|
||||
} from "./BinarySessionOpen.js";
|
||||
import { prepareSessionExecution } from "./BinarySessionExecution.js";
|
||||
import { closeAnalysisClient } from "./AnalysisClientCleanup.js";
|
||||
export type { BinarySessionPort } from "./BinarySessionPort.js";
|
||||
const OFFICIAL_OPERATIONS: ReadonlySet<string> = new Set(
|
||||
OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name),
|
||||
@@ -186,7 +188,17 @@ export class BinarySession
|
||||
await this.#drainCalls();
|
||||
const previous = this.#active;
|
||||
this.#active = undefined;
|
||||
await previous?.client.close();
|
||||
if (previous !== undefined) {
|
||||
const closed = await closeAnalysisClient(
|
||||
previous.client,
|
||||
previous.route.identity.id,
|
||||
);
|
||||
if (!closed.ok) {
|
||||
this.clearSessionRecords();
|
||||
this.#clearRuntimeAvailability();
|
||||
return closed;
|
||||
}
|
||||
}
|
||||
const runId = randomUUID();
|
||||
const client = route.createClient(target, { runId });
|
||||
const started = await client.execute("health", {}, options);
|
||||
@@ -226,15 +238,24 @@ export class BinarySession
|
||||
}
|
||||
|
||||
/** Close the active target, if any. */
|
||||
close(): Promise<Result<null, AnalysisError>> {
|
||||
close(
|
||||
options: Pick<ExecutionOptions, "progress"> = {},
|
||||
): Promise<Result<null, AnalysisError>> {
|
||||
return this.#serialize(async () => {
|
||||
const previous = this.#active;
|
||||
this.#active = undefined;
|
||||
await this.#drainCalls();
|
||||
await previous?.client.close();
|
||||
const closed =
|
||||
previous === undefined
|
||||
? ok(null)
|
||||
: await closeAnalysisClient(
|
||||
previous.client,
|
||||
previous.route.identity.id,
|
||||
options,
|
||||
);
|
||||
this.clearSessionRecords();
|
||||
this.#clearRuntimeAvailability();
|
||||
return ok(null);
|
||||
return closed;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -248,6 +269,8 @@ export class BinarySession
|
||||
runId: this.#active?.runId,
|
||||
runtimeLineageSnapshots:
|
||||
this.#active?.client.runtimeLineageSnapshots?.() ?? [],
|
||||
requestActivitySnapshots:
|
||||
this.#active?.client.requestActivitySnapshots?.() ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import type { ReconstructionCoverageWorkspace } from "../domain/reconstructionCo
|
||||
import type {
|
||||
AnalysisOperation,
|
||||
AnalysisOperationPort,
|
||||
ExecutionOptions,
|
||||
ProviderIdentity,
|
||||
} from "./AnalysisProvider.js";
|
||||
|
||||
@@ -37,7 +38,9 @@ export interface BinarySessionPort extends AnalysisOperationPort {
|
||||
readonly providerId?: AnalysisProviderSelector;
|
||||
},
|
||||
): Promise<Result<BinaryTarget, AnalysisError>>;
|
||||
close(): Promise<Result<null, AnalysisError>>;
|
||||
close(
|
||||
options?: Pick<ExecutionOptions, "progress">,
|
||||
): Promise<Result<null, AnalysisError>>;
|
||||
status(): JsonValue;
|
||||
activeTarget(): BinaryTarget | undefined;
|
||||
recordEvidence(
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type {
|
||||
CapabilityDescriptor,
|
||||
ProviderRequestActivitySnapshot,
|
||||
ProviderRuntimeLineageSnapshot,
|
||||
} from "./AnalysisProvider.js";
|
||||
import type {
|
||||
@@ -19,6 +20,7 @@ interface BinarySessionStatusInput {
|
||||
>;
|
||||
readonly runId: string | undefined;
|
||||
readonly runtimeLineageSnapshots: readonly ProviderRuntimeLineageSnapshot[];
|
||||
readonly requestActivitySnapshots: readonly ProviderRequestActivitySnapshot[];
|
||||
}
|
||||
|
||||
/** Project internal provider routing state into the caller-visible session status. */
|
||||
@@ -29,6 +31,7 @@ export const binarySessionStatus = ({
|
||||
runtimeAvailability,
|
||||
runId,
|
||||
runtimeLineageSnapshots,
|
||||
requestActivitySnapshots,
|
||||
}: BinarySessionStatusInput): JsonValue => {
|
||||
const configuredProvider = router.configuredIdentity();
|
||||
const provider = providerSummary(configuredProvider);
|
||||
@@ -73,6 +76,7 @@ export const binarySessionStatus = ({
|
||||
run_id: runId,
|
||||
process_lineage: processLineageStatus(runtimeLineageSnapshots),
|
||||
},
|
||||
analysis_activity: requestActivityStatus(requestActivitySnapshots),
|
||||
};
|
||||
return target === undefined
|
||||
? { open: false, ...common }
|
||||
@@ -87,6 +91,42 @@ export const binarySessionStatus = ({
|
||||
};
|
||||
};
|
||||
|
||||
const requestActivityStatus = (
|
||||
snapshots: readonly ProviderRequestActivitySnapshot[],
|
||||
) => {
|
||||
const providers = snapshots
|
||||
.slice()
|
||||
.sort((left, right) => left.provider.id.localeCompare(right.provider.id))
|
||||
.map(({ provider, active, queuedRequests }) => ({
|
||||
provider: providerSummary(provider),
|
||||
active:
|
||||
active === null
|
||||
? null
|
||||
: {
|
||||
request_id: active.requestId,
|
||||
operation: active.operation,
|
||||
elapsed_ms: active.elapsedMs,
|
||||
timeout_ms: active.timeoutMs,
|
||||
caller_state: active.callerState,
|
||||
},
|
||||
queued_requests: queuedRequests,
|
||||
}));
|
||||
const active = snapshots.flatMap((snapshot) =>
|
||||
snapshot.active === null ? [] : [snapshot.active],
|
||||
);
|
||||
return {
|
||||
status:
|
||||
snapshots.length === 0
|
||||
? "not_observed"
|
||||
: active.some(({ callerState }) => callerState === "timed_out")
|
||||
? "timed_out_busy"
|
||||
: active.length > 0
|
||||
? "busy"
|
||||
: "idle",
|
||||
providers,
|
||||
};
|
||||
};
|
||||
|
||||
const providerSummary = ({
|
||||
id,
|
||||
name,
|
||||
@@ -147,6 +187,7 @@ const capabilityStatus = (
|
||||
operation: descriptor.operation,
|
||||
available: descriptor.available,
|
||||
reason: descriptor.reason,
|
||||
availability_code: descriptor.availabilityCode ?? null,
|
||||
input_contract_version: descriptor.inputContractVersion,
|
||||
output_contract_version: descriptor.outputContractVersion,
|
||||
pagination: descriptor.pagination,
|
||||
|
||||
@@ -24,9 +24,10 @@ import type {
|
||||
WebMcpDiscovery,
|
||||
} from "../domain/webMcpDiscovery.js";
|
||||
import type {
|
||||
CompareWebCapturesInput,
|
||||
WebCaptureDiff,
|
||||
} from "../domain/webCaptureDiff.js";
|
||||
BrowserCaptureComparison,
|
||||
BrowserCaptureComparisonInput,
|
||||
} from "../domain/browserCaptureComparison.js";
|
||||
import { commitBrowserScenarioNormalization } from "../domain/browserScenarioNormalization.js";
|
||||
import type {
|
||||
CaptureWebScreenshotInput,
|
||||
CompareWebScreenshotsInput,
|
||||
@@ -40,7 +41,7 @@ type BrowserEvidenceInput =
|
||||
| AnalyzeWebBundleInput
|
||||
| ObserveWebSessionInput
|
||||
| DiscoverWebMcpToolsInput
|
||||
| CompareWebCapturesInput
|
||||
| BrowserCaptureComparisonInput
|
||||
| CaptureWebScreenshotInput
|
||||
| CompareWebScreenshotsInput;
|
||||
type BrowserEvidenceResult =
|
||||
@@ -49,7 +50,7 @@ type BrowserEvidenceResult =
|
||||
| WebBundleAnalysis
|
||||
| WebObservationSession
|
||||
| WebMcpDiscovery
|
||||
| WebCaptureDiff
|
||||
| BrowserCaptureComparison
|
||||
| WebScreenshot
|
||||
| WebScreenshotDiff;
|
||||
type BrowserEvidenceOperation =
|
||||
@@ -92,6 +93,22 @@ const browserParameters = (
|
||||
input: BrowserEvidenceInput,
|
||||
): EvidenceObservation["parameters"] => {
|
||||
if (!("cdp_endpoint" in input)) {
|
||||
if (
|
||||
"before_scenario" in input &&
|
||||
input.before_scenario !== undefined &&
|
||||
input.after_scenario !== undefined
|
||||
)
|
||||
return {
|
||||
comparison_kind: "browser_scenario",
|
||||
before_browser: input.before_scenario.browser,
|
||||
after_browser: input.after_scenario.browser,
|
||||
before_start_origin: input.before_scenario.scenario.start_origin,
|
||||
after_start_origin: input.after_scenario.scenario.start_origin,
|
||||
max_changes: input.max_changes,
|
||||
normalization_sha256: commitBrowserScenarioNormalization(
|
||||
input.normalization,
|
||||
).sha256,
|
||||
};
|
||||
if ("max_changes" in input)
|
||||
return {
|
||||
before_target_id: input.before.inspection.target.target_id,
|
||||
@@ -147,6 +164,7 @@ const browserParameters = (
|
||||
include_websocket_shapes: input.include_websocket_shapes,
|
||||
include_script_sources: input.include_script_sources,
|
||||
include_storage_keys: input.include_storage_keys,
|
||||
include_storage_fingerprints: input.include_storage_fingerprints,
|
||||
limits: input.limits,
|
||||
...(input.include_script_sources && "source_capture_approved" in input
|
||||
? {
|
||||
|
||||
@@ -19,9 +19,9 @@ import type {
|
||||
WebMcpDiscovery,
|
||||
} from "../domain/webMcpDiscovery.js";
|
||||
import type {
|
||||
CompareWebCapturesInput,
|
||||
WebCaptureDiff,
|
||||
} from "../domain/webCaptureDiff.js";
|
||||
BrowserCaptureComparison,
|
||||
BrowserCaptureComparisonInput,
|
||||
} from "../domain/browserCaptureComparison.js";
|
||||
import type {
|
||||
CaptureWebScreenshotInput,
|
||||
CompareWebScreenshotsInput,
|
||||
@@ -55,8 +55,8 @@ export interface BrowserObservationPort {
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<WebMcpDiscovery, AnalysisError>>;
|
||||
compareCaptures(
|
||||
input: CompareWebCapturesInput,
|
||||
): Promise<Result<WebCaptureDiff, AnalysisError>>;
|
||||
input: BrowserCaptureComparisonInput,
|
||||
): Promise<Result<BrowserCaptureComparison, AnalysisError>>;
|
||||
captureScreenshot(
|
||||
input: CaptureWebScreenshotInput,
|
||||
options?: ExecutionOptions,
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import type { AnalyzeWebBundleInput } from "../domain/webBundleAnalysis.js";
|
||||
import type { ObserveWebSessionInput } from "../domain/browserSession.js";
|
||||
import type { DiscoverWebMcpToolsInput } from "../domain/webMcpDiscovery.js";
|
||||
import type { CompareWebCapturesInput } from "../domain/webCaptureDiff.js";
|
||||
import type { BrowserCaptureComparisonInput } from "../domain/browserCaptureComparison.js";
|
||||
import type {
|
||||
CaptureWebScreenshotInput,
|
||||
CompareWebScreenshotsInput,
|
||||
@@ -151,7 +151,7 @@ export const discoverWebMcpTools = async (
|
||||
/** Compare two already-normalized web captures without external access. */
|
||||
export const compareWebCaptureEvidence = async (
|
||||
browser: BrowserObservationPort | undefined,
|
||||
input: CompareWebCapturesInput,
|
||||
input: BrowserCaptureComparisonInput,
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const ready = requireBrowser(browser, "compare_web_captures");
|
||||
if (!ready.ok) return ready;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ExecutionOptions, ProviderIdentity } from "./AnalysisProvider.js";
|
||||
import type { AnalysisError } from "../domain/errors.js";
|
||||
import type { BrowserScenario } from "../domain/browserScenario.js";
|
||||
import type { BrowserScenarioCapture } from "../domain/browserScenarioCapture.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
|
||||
/** Provider-neutral application boundary for controlled browser scenarios. */
|
||||
export interface BrowserScenarioCapturePort {
|
||||
identity(): ProviderIdentity;
|
||||
captureScenario(
|
||||
scenario: BrowserScenario,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<BrowserScenarioCapture, AnalysisError>>;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { ExecutionOptions } from "./AnalysisProvider.js";
|
||||
import type { BrowserScenarioCapturePort } from "./BrowserScenarioCapturePort.js";
|
||||
import { createBrowserScenarioEvidence } from "./BrowserScenarioEvidence.js";
|
||||
import type { PermissionAuthority } from "./PermissionAuthority.js";
|
||||
import { digestJson } from "./JavaScriptReplayPlanning.js";
|
||||
import {
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisProtocolError,
|
||||
PermissionRequiredError,
|
||||
type AnalysisError,
|
||||
} from "../domain/errors.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import { isLiteralLoopbackHostname } from "../domain/browserObservation.js";
|
||||
import type { BrowserScenario } from "../domain/browserScenario.js";
|
||||
import { err, type Result } from "../domain/result.js";
|
||||
|
||||
const OPERATION = "capture_browser_scenario" as const;
|
||||
const PROVIDER_ID = "rea-playwright-browser-scenario";
|
||||
|
||||
const networkScope = (origins: readonly string[]): "loopback" | "external" =>
|
||||
origins.every((origin) => isLiteralLoopbackHostname(new URL(origin).hostname))
|
||||
? "loopback"
|
||||
: "external";
|
||||
|
||||
const authorizeScenario = async (
|
||||
authority: PermissionAuthority | undefined,
|
||||
scenario: BrowserScenario,
|
||||
): Promise<Result<true, AnalysisError>> => {
|
||||
if (authority === undefined)
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
PROVIDER_ID,
|
||||
OPERATION,
|
||||
"browser scenario permission policy is not configured",
|
||||
),
|
||||
);
|
||||
const authorized = await authority.authorize(
|
||||
{
|
||||
capability: "browser_automate",
|
||||
roots: [],
|
||||
executables:
|
||||
scenario.browser.mode === "launch"
|
||||
? [scenario.browser.executable_path]
|
||||
: [],
|
||||
environment_names: scenario.secrets.map(
|
||||
({ environment_variable: name }) => name,
|
||||
),
|
||||
origins: [
|
||||
...(scenario.browser.mode === "connect"
|
||||
? [scenario.browser.cdp_endpoint]
|
||||
: []),
|
||||
...scenario.allowed_origins,
|
||||
],
|
||||
network: networkScope(scenario.allowed_origins),
|
||||
mount: false,
|
||||
operation_identity: `${OPERATION}:${digestJson(scenario)}`,
|
||||
},
|
||||
"write",
|
||||
);
|
||||
if (!authorized.ok)
|
||||
return err(
|
||||
authorized.error instanceof PermissionRequiredError
|
||||
? authorized.error
|
||||
: new AnalysisProtocolError(authorized.error.message, {
|
||||
cause: authorized.error,
|
||||
}),
|
||||
);
|
||||
return { ok: true, value: true };
|
||||
};
|
||||
|
||||
/** Authorize and execute one controlled browser scenario. */
|
||||
export const captureBrowserScenario = async (
|
||||
provider: BrowserScenarioCapturePort | undefined,
|
||||
authority: PermissionAuthority | undefined,
|
||||
scenario: BrowserScenario,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const authorized = await authorizeScenario(authority, scenario);
|
||||
if (!authorized.ok) return authorized;
|
||||
if (provider === undefined)
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
PROVIDER_ID,
|
||||
OPERATION,
|
||||
"browser scenario provider is not configured",
|
||||
),
|
||||
);
|
||||
const captured = await provider.captureScenario(scenario, options);
|
||||
return captured.ok
|
||||
? {
|
||||
ok: true,
|
||||
value: createBrowserScenarioEvidence(
|
||||
scenario,
|
||||
captured.value,
|
||||
provider.identity(),
|
||||
),
|
||||
}
|
||||
: captured;
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { ProviderIdentity } from "./AnalysisProvider.js";
|
||||
import type { BrowserScenario } from "../domain/browserScenario.js";
|
||||
import type { BrowserScenarioCapture } from "../domain/browserScenarioCapture.js";
|
||||
import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
|
||||
import {
|
||||
createEvidence,
|
||||
type Evidence,
|
||||
type EvidenceObservation,
|
||||
} from "../domain/evidence.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { digestJson } from "./JavaScriptReplayPlanning.js";
|
||||
|
||||
const browserScenarioParameters = (
|
||||
scenario: BrowserScenario,
|
||||
): EvidenceObservation["parameters"] => ({
|
||||
scenario_sha256: digestJson(scenario),
|
||||
browser_mode: scenario.browser.mode,
|
||||
start_url: sanitizeBrowserUrl(scenario.start_url.url),
|
||||
allowed_origins: scenario.allowed_origins,
|
||||
actions: scenario.actions.map(({ step_id, action }) => ({
|
||||
step_id,
|
||||
action,
|
||||
})),
|
||||
secret_declarations: scenario.secrets.map(
|
||||
({ secret_id, environment_variable, purpose, redaction }) => ({
|
||||
secret_id,
|
||||
environment_variable,
|
||||
purpose,
|
||||
redaction,
|
||||
}),
|
||||
),
|
||||
capture: scenario.capture,
|
||||
limits: scenario.limits,
|
||||
});
|
||||
|
||||
/** Create Evidence v2 without retaining resolved scenario secret values. */
|
||||
export const createBrowserScenarioEvidence = (
|
||||
scenario: BrowserScenario,
|
||||
capture: BrowserScenarioCapture,
|
||||
provider: ProviderIdentity,
|
||||
): Evidence =>
|
||||
createEvidence(undefined, provider, {
|
||||
predicateType: "rea.browser-scenario-capture/v1",
|
||||
operation: "capture_browser_scenario",
|
||||
parameters: browserScenarioParameters(scenario),
|
||||
result: jsonValueSchema.parse(capture),
|
||||
confidence: "observed",
|
||||
authority: "controlled-replay",
|
||||
environment: {
|
||||
id: `${capture.browser.product}@${capture.browser.version}`,
|
||||
platform: process.platform,
|
||||
architecture: process.arch,
|
||||
isolation:
|
||||
capture.browser.process_ownership === "provider-owned"
|
||||
? "process"
|
||||
: "none",
|
||||
},
|
||||
limitations: capture.limitations,
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import type { AnalysisOperation } from "./AnalysisProvider.js";
|
||||
import { projectAnalysisError, type AnalysisError } from "../domain/errors.js";
|
||||
import { parseRelatedAddresses } from "../domain/hopperValues.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { ok, type Result } from "../domain/result.js";
|
||||
import type { EnhancedResult } from "./EnhancedToolTypes.js";
|
||||
|
||||
type AnalysisCall = (
|
||||
name: AnalysisOperation,
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<Result<JsonValue, AnalysisError>>;
|
||||
|
||||
export interface CallPathTraceInput {
|
||||
readonly start: string;
|
||||
readonly goal?: string | undefined;
|
||||
readonly direction: "forward" | "backward";
|
||||
readonly max_depth: number;
|
||||
readonly max_nodes: number;
|
||||
readonly max_operations: number;
|
||||
}
|
||||
|
||||
interface TraceState {
|
||||
readonly visited: Map<
|
||||
string,
|
||||
{ readonly depth: number; readonly path: string[] }
|
||||
>;
|
||||
readonly queue: string[];
|
||||
readonly edges: Map<
|
||||
string,
|
||||
{
|
||||
readonly source_address: string;
|
||||
readonly target_address: string;
|
||||
readonly discovery_depth: number;
|
||||
}
|
||||
>;
|
||||
readonly failures: Array<{
|
||||
readonly address: string;
|
||||
readonly error: ReturnType<typeof projectAnalysisError>;
|
||||
}>;
|
||||
readonly frontier: Set<string>;
|
||||
readonly residual: Set<string>;
|
||||
operations: number;
|
||||
queueIndex: number;
|
||||
traversalPath: string[];
|
||||
}
|
||||
|
||||
interface AddressExpansion {
|
||||
readonly input: CallPathTraceInput;
|
||||
readonly state: TraceState;
|
||||
readonly address: string;
|
||||
readonly current: { readonly depth: number; readonly path: string[] };
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
interface DiscoveredEdge {
|
||||
readonly address: string;
|
||||
readonly relatedAddress: string;
|
||||
readonly discoveryDepth: number;
|
||||
}
|
||||
|
||||
/** Trace one deterministic bounded direct caller/callee path. */
|
||||
export const traceCallPath = async (
|
||||
call: AnalysisCall,
|
||||
input: CallPathTraceInput,
|
||||
signal?: AbortSignal,
|
||||
): EnhancedResult => {
|
||||
const state = createTraceState(input);
|
||||
while (
|
||||
state.queueIndex < state.queue.length &&
|
||||
state.traversalPath.length === 0
|
||||
) {
|
||||
const address = state.queue[state.queueIndex++];
|
||||
if (address === undefined) break;
|
||||
const current = state.visited.get(address);
|
||||
if (current === undefined) continue;
|
||||
if (current.depth >= input.max_depth) {
|
||||
state.frontier.add(address);
|
||||
state.residual.add("Call-path traversal reached the depth limit.");
|
||||
continue;
|
||||
}
|
||||
if (state.operations >= input.max_operations) {
|
||||
recordPendingFrontier(state, input);
|
||||
state.residual.add("Call-path traversal reached the operation budget.");
|
||||
break;
|
||||
}
|
||||
state.operations += 1;
|
||||
const found = await expandAddress(call, {
|
||||
input,
|
||||
state,
|
||||
address,
|
||||
current,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
});
|
||||
if (found) break;
|
||||
}
|
||||
return ok(projectTrace(input, state));
|
||||
};
|
||||
|
||||
const createTraceState = (input: CallPathTraceInput): TraceState => ({
|
||||
visited: new Map([[input.start, { depth: 0, path: [input.start] }]]),
|
||||
queue: [input.start],
|
||||
edges: new Map(),
|
||||
failures: [],
|
||||
frontier: new Set(),
|
||||
residual: new Set(),
|
||||
operations: 0,
|
||||
queueIndex: 0,
|
||||
traversalPath: input.goal === input.start ? [input.start] : [],
|
||||
});
|
||||
|
||||
const expandAddress = async (
|
||||
call: AnalysisCall,
|
||||
expansion: AddressExpansion,
|
||||
): Promise<boolean> => {
|
||||
const { input, state, address, current, signal } = expansion;
|
||||
const relation = input.direction === "forward" ? "callees" : "callers";
|
||||
const tool =
|
||||
input.direction === "forward" ? "procedure_callees" : "procedure_callers";
|
||||
const result = await call(tool, { procedure: address }, signal);
|
||||
if (!result.ok) {
|
||||
state.failures.push({
|
||||
address,
|
||||
error: projectAnalysisError(result.error),
|
||||
});
|
||||
state.residual.add(`Call relationships were unavailable for ${address}.`);
|
||||
return false;
|
||||
}
|
||||
const related = parseRelatedAddresses(result.value, relation);
|
||||
if (!related.ok) {
|
||||
state.failures.push({
|
||||
address,
|
||||
error: projectAnalysisError(related.error),
|
||||
});
|
||||
state.residual.add(`Call relationships were unreadable for ${address}.`);
|
||||
return false;
|
||||
}
|
||||
for (const relatedAddress of sortedUniqueAddresses(related.value)) {
|
||||
recordEdge(input, state, {
|
||||
address,
|
||||
relatedAddress,
|
||||
discoveryDepth: current.depth + 1,
|
||||
});
|
||||
const path = state.visited.get(relatedAddress)?.path ?? [
|
||||
...current.path,
|
||||
relatedAddress,
|
||||
];
|
||||
if (state.visited.has(relatedAddress)) {
|
||||
if (relatedAddress === input.goal) {
|
||||
state.traversalPath = path;
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (state.visited.size >= input.max_nodes) {
|
||||
if (state.frontier.size < input.max_nodes)
|
||||
state.frontier.add(relatedAddress);
|
||||
state.residual.add("Call-path traversal reached the node limit.");
|
||||
continue;
|
||||
}
|
||||
state.visited.set(relatedAddress, {
|
||||
depth: current.depth + 1,
|
||||
path,
|
||||
});
|
||||
if (relatedAddress === input.goal) {
|
||||
state.traversalPath = path;
|
||||
return true;
|
||||
}
|
||||
state.queue.push(relatedAddress);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const recordEdge = (
|
||||
input: CallPathTraceInput,
|
||||
state: TraceState,
|
||||
edge: DiscoveredEdge,
|
||||
): void => {
|
||||
const sourceAddress =
|
||||
input.direction === "forward" ? edge.address : edge.relatedAddress;
|
||||
const targetAddress =
|
||||
input.direction === "forward" ? edge.relatedAddress : edge.address;
|
||||
const edgeKey = `${sourceAddress}\u0000${targetAddress}`;
|
||||
if (state.edges.has(edgeKey)) return;
|
||||
if (state.edges.size >= input.max_nodes) {
|
||||
state.residual.add("Call-path traversal reached the edge limit.");
|
||||
return;
|
||||
}
|
||||
state.edges.set(edgeKey, {
|
||||
source_address: sourceAddress,
|
||||
target_address: targetAddress,
|
||||
discovery_depth: edge.discoveryDepth,
|
||||
});
|
||||
};
|
||||
|
||||
const recordPendingFrontier = (
|
||||
state: TraceState,
|
||||
input: CallPathTraceInput,
|
||||
): void => {
|
||||
for (const pending of state.queue.slice(state.queueIndex - 1))
|
||||
if (state.frontier.size < input.max_nodes) state.frontier.add(pending);
|
||||
};
|
||||
|
||||
const projectTrace = (input: CallPathTraceInput, state: TraceState) => ({
|
||||
start: input.start,
|
||||
goal: input.goal ?? null,
|
||||
direction: input.direction,
|
||||
goal_status:
|
||||
input.goal === undefined
|
||||
? "not_requested"
|
||||
: state.traversalPath.length > 0
|
||||
? "reached"
|
||||
: "not_reached",
|
||||
nodes: [...state.visited.entries()]
|
||||
.map(([address, { depth }]) => ({ address, depth }))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.depth - right.depth || compareAddress(left.address, right.address),
|
||||
),
|
||||
edges: [...state.edges.values()].sort(
|
||||
(left, right) =>
|
||||
left.discovery_depth - right.discovery_depth ||
|
||||
compareAddress(left.source_address, right.source_address) ||
|
||||
compareAddress(left.target_address, right.target_address),
|
||||
),
|
||||
traversal_path: state.traversalPath,
|
||||
failures: state.failures,
|
||||
frontier: [...state.frontier].sort(compareAddress),
|
||||
limits: {
|
||||
max_depth: input.max_depth,
|
||||
max_nodes: input.max_nodes,
|
||||
max_operations: input.max_operations,
|
||||
nodes_visited: state.visited.size,
|
||||
operations_used: state.operations,
|
||||
},
|
||||
truncated: state.residual.size > 0,
|
||||
residual_unknowns: [...state.residual],
|
||||
limitations: [
|
||||
"Direct provider call relationships may omit unresolved indirect calls.",
|
||||
"A not_reached goal is only absent from this bounded traversal, not from the complete program.",
|
||||
],
|
||||
});
|
||||
|
||||
const compareAddress = (left: string, right: string): number =>
|
||||
left < right ? -1 : left > right ? 1 : 0;
|
||||
|
||||
const sortedUniqueAddresses = (addresses: readonly string[]): string[] =>
|
||||
[...new Set(addresses)].sort(compareAddress);
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { TOOL_CONTRACTS } from "../contracts/toolContracts.js";
|
||||
import type { ClientFeatureAvailability } from "../contracts/toolOutputSchemaPrimitives.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
|
||||
const statusSchema = z.object({
|
||||
@@ -12,12 +13,14 @@ const statusSchema = z.object({
|
||||
operation: z.string(),
|
||||
available: z.boolean(),
|
||||
reason: z.string().nullable(),
|
||||
availability_code: z.string().nullable().optional(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
type ToolAvailabilityReason =
|
||||
| "available"
|
||||
| "client_capability_missing"
|
||||
| "target_required"
|
||||
| "provider_missing"
|
||||
| "provider_unavailable"
|
||||
@@ -29,16 +32,43 @@ type ToolKind = (typeof TOOL_CONTRACTS)[number]["kind"];
|
||||
type ProviderDescriptor = {
|
||||
readonly available: boolean;
|
||||
readonly reason: string | null;
|
||||
readonly availability_code?: string | null;
|
||||
};
|
||||
type AvailabilityPolicy = {
|
||||
export type AvailabilityPolicy = {
|
||||
readonly processCaptureEnabled: boolean;
|
||||
readonly evidenceFileRoots: number;
|
||||
readonly investigationInputRoots: number;
|
||||
readonly browserObservationEnabled?: boolean;
|
||||
readonly browserScenarioEnabled?: boolean;
|
||||
readonly electronObservationEnabled?: boolean;
|
||||
readonly v8InspectorObservationEnabled?: boolean;
|
||||
readonly javascriptReplayEnabled?: boolean;
|
||||
readonly managedRuntimeEnabled?: boolean;
|
||||
};
|
||||
|
||||
type ClientFeatureName = keyof ClientFeatureAvailability;
|
||||
|
||||
const NO_CLIENT_FEATURES: ClientFeatureAvailability = {
|
||||
elicitation_form: false,
|
||||
elicitation_url: false,
|
||||
roots: false,
|
||||
sampling: false,
|
||||
};
|
||||
|
||||
const CLIENT_FEATURE_REQUIREMENTS: Readonly<
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
readonly required: readonly ClientFeatureName[];
|
||||
readonly optional: readonly ClientFeatureName[];
|
||||
}
|
||||
>
|
||||
> = {
|
||||
capture_process_scenario: {
|
||||
required: [],
|
||||
optional: ["elicitation_form"],
|
||||
},
|
||||
};
|
||||
type Availability = {
|
||||
readonly reason: ToolAvailabilityReason;
|
||||
readonly remediation: string | null;
|
||||
@@ -80,14 +110,22 @@ const ENHANCED_REQUIREMENTS: Readonly<Record<string, readonly string[]>> = {
|
||||
],
|
||||
};
|
||||
|
||||
/** Build stable per-operation availability without hiding familiar tools. */
|
||||
/** Build stable per-operation availability for discovery and tool visibility. */
|
||||
export const buildCapabilityInventory = (
|
||||
sessionStatus: JsonValue,
|
||||
policy: AvailabilityPolicy,
|
||||
clientFeatures: ClientFeatureAvailability = NO_CLIENT_FEATURES,
|
||||
) => {
|
||||
const status = statusSchema.parse(sessionStatus);
|
||||
const descriptors = new Map(
|
||||
status.capabilities.map((descriptor) => [descriptor.operation, descriptor]),
|
||||
const descriptors = new Map<string, ProviderDescriptor>(
|
||||
status.capabilities.map((descriptor) => [
|
||||
descriptor.operation,
|
||||
{
|
||||
available: descriptor.available,
|
||||
reason: descriptor.reason,
|
||||
availability_code: descriptor.availability_code ?? null,
|
||||
},
|
||||
]),
|
||||
);
|
||||
return TOOL_CONTRACTS.map((contract) => {
|
||||
const availability = availabilityFor({
|
||||
@@ -98,12 +136,22 @@ export const buildCapabilityInventory = (
|
||||
descriptors,
|
||||
policy,
|
||||
});
|
||||
const clientRequirements = clientRequirementsFor(
|
||||
contract.name,
|
||||
clientFeatures,
|
||||
);
|
||||
const clientBlocked = clientRequirements.missing_required.length > 0;
|
||||
return {
|
||||
name: contract.name,
|
||||
surface: contract.kind,
|
||||
available: availability.reason === "available",
|
||||
reason: availability.reason,
|
||||
remediation: availability.remediation,
|
||||
available: availability.reason === "available" && !clientBlocked,
|
||||
reason: clientBlocked
|
||||
? ("client_capability_missing" as const)
|
||||
: availability.reason,
|
||||
remediation: clientBlocked
|
||||
? `Use an MCP client that supports: ${clientRequirements.missing_required.join(", ")}.`
|
||||
: availability.remediation,
|
||||
client_requirements: clientRequirements,
|
||||
effects: { ...contract.effects },
|
||||
annotations: {
|
||||
read_only: contract.annotations.readOnlyHint ?? false,
|
||||
@@ -115,6 +163,26 @@ export const buildCapabilityInventory = (
|
||||
}).sort((left, right) => left.name.localeCompare(right.name));
|
||||
};
|
||||
|
||||
const clientRequirementsFor = (
|
||||
name: string,
|
||||
clientFeatures: ClientFeatureAvailability,
|
||||
) => {
|
||||
const requirements = CLIENT_FEATURE_REQUIREMENTS[name] ?? {
|
||||
required: [],
|
||||
optional: [],
|
||||
};
|
||||
return {
|
||||
required: [...requirements.required],
|
||||
optional: [...requirements.optional],
|
||||
missing_required: requirements.required.filter(
|
||||
(feature) => !clientFeatures[feature],
|
||||
),
|
||||
missing_optional: requirements.optional.filter(
|
||||
(feature) => !clientFeatures[feature],
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const availabilityFor = (context: AvailabilityContext): Availability => {
|
||||
const javascriptApplication = javascriptApplicationAvailability(context);
|
||||
if (javascriptApplication !== null) return javascriptApplication;
|
||||
@@ -147,6 +215,8 @@ const policyAvailability = ({
|
||||
kind,
|
||||
policy,
|
||||
}: AvailabilityContext): Availability | null => {
|
||||
const browser = browserPolicyAvailability(name, kind, policy);
|
||||
if (browser !== null) return browser;
|
||||
if (name === "capture_process_scenario" && !policy.processCaptureEnabled)
|
||||
return {
|
||||
reason: "policy_disabled",
|
||||
@@ -178,14 +248,6 @@ const policyAvailability = ({
|
||||
reason: "policy_disabled",
|
||||
remediation: "Configure or grant an evidence_read root.",
|
||||
};
|
||||
if (kind === "browser-provider")
|
||||
return policy.browserObservationEnabled === true
|
||||
? { reason: "available", remediation: null }
|
||||
: {
|
||||
reason: "policy_disabled",
|
||||
remediation:
|
||||
"Enable browser observation and configure exact CDP endpoint and page origins.",
|
||||
};
|
||||
if (kind === "electron-provider")
|
||||
return policy.electronObservationEnabled === true
|
||||
? { reason: "available", remediation: null }
|
||||
@@ -194,16 +256,47 @@ const policyAvailability = ({
|
||||
remediation:
|
||||
"Enable Electron observation and configure a loopback CDP endpoint and canonical file roots.",
|
||||
};
|
||||
if (kind === "runtime-provider")
|
||||
return policy.v8InspectorObservationEnabled === true
|
||||
? { reason: "available", remediation: null }
|
||||
: {
|
||||
reason: "policy_disabled",
|
||||
remediation:
|
||||
"Enable V8 Inspector observation and configure exact loopback endpoints plus canonical file roots or exact origins.",
|
||||
};
|
||||
if (kind === "session") return { reason: "available", remediation: null };
|
||||
return null;
|
||||
};
|
||||
|
||||
const browserPolicyAvailability = (
|
||||
name: string,
|
||||
kind: ToolKind,
|
||||
policy: AvailabilityPolicy,
|
||||
): Availability | null => {
|
||||
if (name === "capture_browser_scenario")
|
||||
return policy.browserScenarioEnabled === true
|
||||
? { reason: "available", remediation: null }
|
||||
: {
|
||||
reason: "policy_disabled",
|
||||
remediation:
|
||||
"Enable browser scenarios and configure exact executable roots or loopback CDP endpoints, page origins, and environment secret names.",
|
||||
};
|
||||
if (kind !== "browser-provider") return null;
|
||||
return policy.browserObservationEnabled === true
|
||||
? { reason: "available", remediation: null }
|
||||
: {
|
||||
reason: "policy_disabled",
|
||||
remediation:
|
||||
"Enable browser observation and configure exact CDP endpoint and page origins.",
|
||||
};
|
||||
};
|
||||
|
||||
const targetAvailability = ({
|
||||
kind,
|
||||
targetKind,
|
||||
targetOpen,
|
||||
}: AvailabilityContext): Availability | null => {
|
||||
if (!targetOpen)
|
||||
if (!targetOpen && (kind === "official-proxy" || kind === "enhanced"))
|
||||
return {
|
||||
reason: "target_required",
|
||||
remediation: "Call open_binary with a supported local target.",
|
||||
@@ -229,6 +322,13 @@ const providerAvailability = ({
|
||||
}: AvailabilityContext): Availability => {
|
||||
if (kind === "enhanced") return composedAvailability(name, descriptors);
|
||||
const descriptor = descriptors.get(name);
|
||||
if (
|
||||
descriptor === undefined &&
|
||||
(kind === "artifact-provider" ||
|
||||
kind === "managed-provider" ||
|
||||
kind === "native-provider")
|
||||
)
|
||||
return { reason: "available", remediation: null };
|
||||
if (descriptor === undefined)
|
||||
return {
|
||||
reason: "provider_missing",
|
||||
@@ -237,7 +337,7 @@ const providerAvailability = ({
|
||||
};
|
||||
if (
|
||||
!descriptor.available &&
|
||||
descriptor.reason?.toLowerCase().includes("require macos") === true
|
||||
descriptor.availability_code === "unsupported_host"
|
||||
)
|
||||
return {
|
||||
reason: "unsupported_host",
|
||||
@@ -254,10 +354,7 @@ const providerAvailability = ({
|
||||
|
||||
const composedAvailability = (
|
||||
name: string,
|
||||
descriptors: ReadonlyMap<
|
||||
string,
|
||||
{ readonly available: boolean; readonly reason: string | null }
|
||||
>,
|
||||
descriptors: ReadonlyMap<string, ProviderDescriptor>,
|
||||
): {
|
||||
readonly reason: ToolAvailabilityReason;
|
||||
readonly remediation: string | null;
|
||||
@@ -280,7 +377,7 @@ const composedAvailability = (
|
||||
if (unavailable !== undefined)
|
||||
return {
|
||||
reason:
|
||||
unavailable.reason?.toLowerCase().includes("require macos") === true
|
||||
unavailable.availability_code === "unsupported_host"
|
||||
? "unsupported_host"
|
||||
: "provider_unavailable",
|
||||
remediation:
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
CapabilityDescriptor,
|
||||
ProviderIdentity,
|
||||
} from "./AnalysisProvider.js";
|
||||
import { closeAnalysisClient } from "./AnalysisClientCleanup.js";
|
||||
|
||||
/** Synthetic compatibility identity for a deterministic provider set. */
|
||||
export const compositeProviderIdentity = (
|
||||
@@ -103,6 +104,16 @@ export class CompositeProvider implements AnalysisProvider {
|
||||
clients.set(provider, created);
|
||||
return created;
|
||||
};
|
||||
const closeWithOutcome: NonNullable<
|
||||
AnalysisClient["closeWithOutcome"]
|
||||
> = async (options) => {
|
||||
const outcomes = await Promise.all(
|
||||
[...clients.entries()].map(([provider, client]) =>
|
||||
closeAnalysisClient(client, provider.identity().id, options),
|
||||
),
|
||||
);
|
||||
return outcomes.find((outcome) => !outcome.ok) ?? ok(null);
|
||||
};
|
||||
return {
|
||||
execute: (operation, parameters, options) => {
|
||||
if (operation === "health")
|
||||
@@ -135,10 +146,15 @@ export class CompositeProvider implements AnalysisProvider {
|
||||
.sort((left, right) =>
|
||||
left.provider.id.localeCompare(right.provider.id),
|
||||
),
|
||||
requestActivitySnapshots: () =>
|
||||
[...clients.values()]
|
||||
.flatMap((client) => client.requestActivitySnapshots?.() ?? [])
|
||||
.sort((left, right) =>
|
||||
left.provider.id.localeCompare(right.provider.id),
|
||||
),
|
||||
closeWithOutcome,
|
||||
close: async () => {
|
||||
await Promise.allSettled(
|
||||
[...clients.values()].map(async (client) => client.close()),
|
||||
);
|
||||
await closeWithOutcome();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
workflowAnalysisProfile,
|
||||
} from "./InvestigationProviders.js";
|
||||
import type { AnalysisProviderSelector } from "../contracts/providerSelection.js";
|
||||
import { artifactInspectionResultSchema } from "../domain/artifactInspection.js";
|
||||
|
||||
export {
|
||||
runCapabilityStatus,
|
||||
@@ -49,6 +50,7 @@ export {
|
||||
type DirectAnalysisTool =
|
||||
| "binary_overview"
|
||||
| "procedure_pseudo_code"
|
||||
| "read_function_instructions"
|
||||
| "analyze_function"
|
||||
| "search_strings"
|
||||
| "search_procedures"
|
||||
@@ -143,7 +145,7 @@ const authorizeAnalysis = async (
|
||||
);
|
||||
}
|
||||
if (
|
||||
tool === "inventory_artifact" &&
|
||||
["inventory_artifact", "inspect_artifact"].includes(tool) &&
|
||||
arguments_.native_mount_approved === true
|
||||
) {
|
||||
const result = await authority.authorize(
|
||||
@@ -154,7 +156,7 @@ const authorizeAnalysis = async (
|
||||
environment_names: [],
|
||||
network: "none",
|
||||
mount: true,
|
||||
operation_identity: "inventory_artifact:native_mount",
|
||||
operation_identity: `${tool}:native_mount`,
|
||||
},
|
||||
"read",
|
||||
);
|
||||
@@ -380,6 +382,11 @@ const executeAnalysisTool = async (input: {
|
||||
rawResult: result.value.rawResult,
|
||||
limitations: result.value.limitations,
|
||||
locations: result.value.locations,
|
||||
evidenceLinks:
|
||||
tool === "inspect_artifact"
|
||||
? artifactInspectionResultSchema.parse(result.value.result)
|
||||
.evidence_links
|
||||
: [],
|
||||
},
|
||||
);
|
||||
return { output: evidence, evidence };
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { AnalysisOperation } from "./AnalysisProvider.js";
|
||||
import { AnalysisOutputError, type AnalysisError } from "../domain/errors.js";
|
||||
import { parseAddressedPage } from "../domain/hopperValues.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type {
|
||||
EnhancedResult,
|
||||
TraceMatch,
|
||||
TraceReference,
|
||||
} from "./EnhancedToolTypes.js";
|
||||
|
||||
type AnalysisCall = (
|
||||
name: AnalysisOperation,
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<Result<JsonValue, AnalysisError>>;
|
||||
|
||||
type LiteralSearchSource = readonly [
|
||||
"list_strings" | "list_procedures",
|
||||
"string" | "procedure",
|
||||
];
|
||||
|
||||
interface ReferenceTraceRequest {
|
||||
readonly matches: readonly TraceMatch[];
|
||||
readonly limit: number;
|
||||
readonly operationBudget: number;
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
const FEATURE_SEARCH_SOURCES: readonly LiteralSearchSource[] = [
|
||||
["list_strings", "string"],
|
||||
["list_procedures", "procedure"],
|
||||
];
|
||||
const STRING_SEARCH_SOURCES: readonly LiteralSearchSource[] = [
|
||||
["list_strings", "string"],
|
||||
];
|
||||
|
||||
export interface LiteralTraceInput {
|
||||
readonly query: string;
|
||||
readonly case_sensitive: boolean;
|
||||
readonly limit: number;
|
||||
readonly max_operations: number;
|
||||
}
|
||||
|
||||
/** Trace a bounded feature or string literal through provider xrefs. */
|
||||
export const traceLiteralFeature = async (
|
||||
call: AnalysisCall,
|
||||
input: LiteralTraceInput,
|
||||
scope: "feature" | "string",
|
||||
signal?: AbortSignal,
|
||||
): EnhancedResult => {
|
||||
const sources =
|
||||
scope === "feature" ? FEATURE_SEARCH_SOURCES : STRING_SEARCH_SOURCES;
|
||||
const searched = await literalMatches(call, input, sources, signal);
|
||||
if (!searched.ok) return searched;
|
||||
const residual = new Set<string>();
|
||||
const traced = await traceReferences(call, {
|
||||
matches: searched.value.matches,
|
||||
limit: input.limit,
|
||||
operationBudget: input.max_operations - searched.value.operations,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
});
|
||||
if (!traced.ok) return traced;
|
||||
for (const reason of traced.value.residual) residual.add(reason);
|
||||
const operations = searched.value.operations + traced.value.operations;
|
||||
if (operations >= input.max_operations)
|
||||
residual.add("Investigation reached the operation budget.");
|
||||
if (searched.value.matches.length >= input.limit)
|
||||
residual.add("Literal matches reached the configured limit.");
|
||||
return ok({
|
||||
query: input.query,
|
||||
search_mode: "literal",
|
||||
operations_used: operations,
|
||||
operation_budget: input.max_operations,
|
||||
matches: searched.value.matches,
|
||||
references: traced.value.references,
|
||||
truncated: residual.size > 0,
|
||||
residual_unknowns: [...residual],
|
||||
});
|
||||
};
|
||||
|
||||
const literalMatches = async (
|
||||
call: AnalysisCall,
|
||||
input: LiteralTraceInput,
|
||||
sources: readonly LiteralSearchSource[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<
|
||||
Result<{ matches: TraceMatch[]; operations: number }, AnalysisError>
|
||||
> => {
|
||||
let operations = 0;
|
||||
const matches: TraceMatch[] = [];
|
||||
const needle = input.case_sensitive ? input.query : input.query.toLowerCase();
|
||||
for (const [tool, type] of sources) {
|
||||
let offset = 0;
|
||||
while (operations < input.max_operations && matches.length < input.limit) {
|
||||
operations += 1;
|
||||
const result = await call(tool, { offset, limit: 500 }, signal);
|
||||
if (!result.ok) return result;
|
||||
const page = parseAddressedPage(result.value);
|
||||
if (!page.ok) return page;
|
||||
for (const item of page.value.items) {
|
||||
const haystack = input.case_sensitive
|
||||
? item.name
|
||||
: item.name.toLowerCase();
|
||||
if (haystack.includes(needle))
|
||||
matches.push({ type, address: item.address, value: item.name });
|
||||
if (matches.length >= input.limit) break;
|
||||
}
|
||||
if (!page.value.hasMore || page.value.nextOffset === null) break;
|
||||
offset = page.value.nextOffset;
|
||||
}
|
||||
}
|
||||
return ok({ matches, operations });
|
||||
};
|
||||
|
||||
const traceReferences = async (
|
||||
call: AnalysisCall,
|
||||
request: ReferenceTraceRequest,
|
||||
): Promise<
|
||||
Result<
|
||||
{ references: TraceReference[]; operations: number; residual: string[] },
|
||||
AnalysisError
|
||||
>
|
||||
> => {
|
||||
let operations = 0;
|
||||
const references: TraceReference[] = [];
|
||||
const residual = new Set<string>();
|
||||
for (const match of request.matches) {
|
||||
if (operations >= request.operationBudget) {
|
||||
residual.add("Reference traversal stopped at the operation budget.");
|
||||
break;
|
||||
}
|
||||
operations += 1;
|
||||
const xrefs = await call(
|
||||
"xrefs",
|
||||
{ address: match.address },
|
||||
request.signal,
|
||||
);
|
||||
if (!xrefs.ok) return xrefs;
|
||||
if (!Array.isArray(xrefs.value))
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
"xrefs",
|
||||
"provider returned a non-array result",
|
||||
),
|
||||
);
|
||||
for (const source of xrefs.value) {
|
||||
if (typeof source !== "string")
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
"xrefs",
|
||||
"provider returned a non-address value",
|
||||
),
|
||||
);
|
||||
if (operations >= request.operationBudget) {
|
||||
residual.add(
|
||||
"Containing-procedure resolution stopped at the operation budget.",
|
||||
);
|
||||
break;
|
||||
}
|
||||
operations += 1;
|
||||
const resolved = await call(
|
||||
"resolve_containing_procedure",
|
||||
{ address: source },
|
||||
request.signal,
|
||||
);
|
||||
if (!resolved.ok) return resolved;
|
||||
references.push({
|
||||
target_address: match.address,
|
||||
source_address: source,
|
||||
containing_procedure: resolved.value,
|
||||
});
|
||||
if (references.length >= request.limit) {
|
||||
residual.add("Reference results reached the configured limit.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (references.length >= request.limit) break;
|
||||
}
|
||||
return ok({
|
||||
references,
|
||||
operations,
|
||||
residual: [...residual],
|
||||
});
|
||||
};
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
} from "../domain/errors.js";
|
||||
import {
|
||||
addressDistance,
|
||||
parseAddressedPage,
|
||||
parseDocuments,
|
||||
parseFunctionDossier,
|
||||
parseListCount,
|
||||
@@ -31,11 +30,11 @@ import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import {
|
||||
invalidEnhancedInput,
|
||||
type EnhancedResult,
|
||||
type TraceMatch,
|
||||
type TraceReference,
|
||||
type ValidatedEnhancedCall,
|
||||
} from "./EnhancedToolTypes.js";
|
||||
import { readAllAddressed } from "./EnhancedToolPagination.js";
|
||||
import { traceCallPath } from "./CallPathTracing.js";
|
||||
import { traceLiteralFeature } from "./EnhancedLiteralTracing.js";
|
||||
export type { ValidatedEnhancedCall } from "./EnhancedToolTypes.js";
|
||||
|
||||
/**
|
||||
@@ -52,6 +51,12 @@ export class EnhancedTools {
|
||||
input: unknown,
|
||||
signal?: AbortSignal,
|
||||
): EnhancedResult {
|
||||
if (
|
||||
name === "trace_feature" ||
|
||||
name === "find_code_for_string" ||
|
||||
name === "trace_call_path"
|
||||
)
|
||||
return this.#executeTracing(name, input, signal);
|
||||
switch (name) {
|
||||
case "swift_classes": {
|
||||
const parsed = enhancedInputSchemas.swift_classes.safeParse(input);
|
||||
@@ -99,12 +104,6 @@ export class EnhancedTools {
|
||||
? this.executeValidated({ name, input: parsed.data }, signal)
|
||||
: invalidEnhancedInput(name, parsed.error);
|
||||
}
|
||||
case "trace_feature": {
|
||||
const parsed = enhancedInputSchemas.trace_feature.safeParse(input);
|
||||
return parsed.success
|
||||
? this.executeValidated({ name, input: parsed.data }, signal)
|
||||
: invalidEnhancedInput(name, parsed.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,10 +132,55 @@ export class EnhancedTools {
|
||||
case "analyze_function":
|
||||
return this.#analyzeFunction(call.input, signal);
|
||||
case "trace_feature":
|
||||
return this.#traceFeature(call.input, signal);
|
||||
return traceLiteralFeature(
|
||||
(name, arguments_, operationSignal) =>
|
||||
this.#call(name, arguments_, operationSignal),
|
||||
call.input,
|
||||
"feature",
|
||||
signal,
|
||||
);
|
||||
case "find_code_for_string":
|
||||
return traceLiteralFeature(
|
||||
(name, arguments_, operationSignal) =>
|
||||
this.#call(name, arguments_, operationSignal),
|
||||
call.input,
|
||||
"string",
|
||||
signal,
|
||||
);
|
||||
case "trace_call_path":
|
||||
return traceCallPath(
|
||||
(name, arguments_, operationSignal) =>
|
||||
this.#call(name, arguments_, operationSignal),
|
||||
call.input,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#executeTracing(
|
||||
name: "trace_feature" | "find_code_for_string" | "trace_call_path",
|
||||
input: unknown,
|
||||
signal?: AbortSignal,
|
||||
): EnhancedResult {
|
||||
const parsed = enhancedInputSchemas[name].safeParse(input);
|
||||
if (!parsed.success) return invalidEnhancedInput(name, parsed.error);
|
||||
if (name === "trace_call_path")
|
||||
return this.executeValidated(
|
||||
{
|
||||
name,
|
||||
input: enhancedInputSchemas.trace_call_path.parse(parsed.data),
|
||||
},
|
||||
signal,
|
||||
);
|
||||
return this.executeValidated(
|
||||
{
|
||||
name,
|
||||
input: enhancedInputSchemas[name].parse(parsed.data),
|
||||
},
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
async #analyzeFunction(
|
||||
input: Readonly<Record<string, JsonValue>>,
|
||||
signal?: AbortSignal,
|
||||
@@ -309,22 +353,24 @@ export class EnhancedTools {
|
||||
input: { detail: "concise" | "detailed"; limit: number },
|
||||
signal?: AbortSignal,
|
||||
): EnhancedResult {
|
||||
const [segmentsResult, documentsResult, procedures, stringsResult] =
|
||||
const [segmentsResult, documentsResult, proceduresResult, stringsResult] =
|
||||
await Promise.all([
|
||||
this.#call("list_segments", {}, signal),
|
||||
this.#call("list_documents", {}, signal),
|
||||
this.#allAddressed("list_procedures", signal),
|
||||
this.#call("list_strings", {}, signal),
|
||||
this.#call("list_procedures", { offset: 0, limit: 1 }, signal),
|
||||
this.#call("list_strings", { offset: 0, limit: 1 }, signal),
|
||||
]);
|
||||
if (!segmentsResult.ok) return segmentsResult;
|
||||
if (!documentsResult.ok) return documentsResult;
|
||||
if (!procedures.ok) return procedures;
|
||||
if (!proceduresResult.ok) return proceduresResult;
|
||||
if (!stringsResult.ok) return stringsResult;
|
||||
|
||||
const segments = parseSegments(segmentsResult.value);
|
||||
if (!segments.ok) return segments;
|
||||
const documents = parseDocuments(documentsResult.value);
|
||||
if (!documents.ok) return documents;
|
||||
const procedureCount = parseListCount(proceduresResult.value, "procedures");
|
||||
if (!procedureCount.ok) return procedureCount;
|
||||
const stringCount = parseListCount(stringsResult.value, "strings");
|
||||
if (!stringCount.ok) return stringCount;
|
||||
|
||||
@@ -339,166 +385,11 @@ export class EnhancedTools {
|
||||
: { name, start, end },
|
||||
),
|
||||
segment_count: segments.value.length,
|
||||
procedure_count: procedures.value.length,
|
||||
procedure_count: procedureCount.value,
|
||||
string_count: stringCount.value,
|
||||
});
|
||||
}
|
||||
|
||||
async #traceFeature(
|
||||
input: {
|
||||
readonly query: string;
|
||||
readonly case_sensitive: boolean;
|
||||
readonly limit: number;
|
||||
readonly max_operations: number;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
): EnhancedResult {
|
||||
const searched = await this.#literalMatches(input, signal);
|
||||
if (!searched.ok) return searched;
|
||||
const residual = new Set<string>();
|
||||
const traced = await this.#traceReferences(
|
||||
searched.value.matches,
|
||||
input.limit,
|
||||
input.max_operations - searched.value.operations,
|
||||
signal,
|
||||
);
|
||||
if (!traced.ok) return traced;
|
||||
for (const reason of traced.value.residual) residual.add(reason);
|
||||
const operations = searched.value.operations + traced.value.operations;
|
||||
if (operations >= input.max_operations)
|
||||
residual.add("Investigation reached the operation budget.");
|
||||
if (searched.value.matches.length >= input.limit)
|
||||
residual.add("Literal matches reached the configured limit.");
|
||||
return ok({
|
||||
query: input.query,
|
||||
search_mode: "literal",
|
||||
operations_used: operations,
|
||||
operation_budget: input.max_operations,
|
||||
matches: searched.value.matches,
|
||||
references: traced.value.references,
|
||||
truncated: residual.size > 0,
|
||||
residual_unknowns: [...residual],
|
||||
});
|
||||
}
|
||||
|
||||
async #literalMatches(
|
||||
input: {
|
||||
readonly query: string;
|
||||
readonly case_sensitive: boolean;
|
||||
readonly limit: number;
|
||||
readonly max_operations: number;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
): Promise<
|
||||
Result<{ matches: TraceMatch[]; operations: number }, AnalysisError>
|
||||
> {
|
||||
let operations = 0;
|
||||
const matches: TraceMatch[] = [];
|
||||
const needle = input.case_sensitive
|
||||
? input.query
|
||||
: input.query.toLowerCase();
|
||||
for (const [tool, type] of [
|
||||
["list_strings", "string"],
|
||||
["list_procedures", "procedure"],
|
||||
] as const) {
|
||||
let offset = 0;
|
||||
while (
|
||||
operations < input.max_operations &&
|
||||
matches.length < input.limit
|
||||
) {
|
||||
operations += 1;
|
||||
const result = await this.#call(tool, { offset, limit: 500 }, signal);
|
||||
if (!result.ok) return result;
|
||||
const page = parseAddressedPage(result.value);
|
||||
if (!page.ok) return page;
|
||||
for (const item of page.value.items) {
|
||||
const haystack = input.case_sensitive
|
||||
? item.name
|
||||
: item.name.toLowerCase();
|
||||
if (haystack.includes(needle))
|
||||
matches.push({ type, address: item.address, value: item.name });
|
||||
if (matches.length >= input.limit) break;
|
||||
}
|
||||
if (!page.value.hasMore || page.value.nextOffset === null) break;
|
||||
offset = page.value.nextOffset;
|
||||
}
|
||||
}
|
||||
return ok({ matches, operations });
|
||||
}
|
||||
|
||||
async #traceReferences(
|
||||
matches: readonly TraceMatch[],
|
||||
limit: number,
|
||||
operationBudget: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<
|
||||
Result<
|
||||
{ references: TraceReference[]; operations: number; residual: string[] },
|
||||
AnalysisError
|
||||
>
|
||||
> {
|
||||
let operations = 0;
|
||||
const references: TraceReference[] = [];
|
||||
const residual = new Set<string>();
|
||||
for (const match of matches) {
|
||||
if (operations >= operationBudget) {
|
||||
residual.add("Reference traversal stopped at the operation budget.");
|
||||
break;
|
||||
}
|
||||
operations += 1;
|
||||
const xrefs = await this.#call(
|
||||
"xrefs",
|
||||
{ address: match.address },
|
||||
signal,
|
||||
);
|
||||
if (!xrefs.ok) return xrefs;
|
||||
if (!Array.isArray(xrefs.value))
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
"xrefs",
|
||||
"provider returned a non-array result",
|
||||
),
|
||||
);
|
||||
for (const source of xrefs.value) {
|
||||
if (typeof source !== "string")
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
"xrefs",
|
||||
"provider returned a non-address value",
|
||||
),
|
||||
);
|
||||
if (operations >= operationBudget) {
|
||||
residual.add(
|
||||
"Containing-procedure resolution stopped at the operation budget.",
|
||||
);
|
||||
break;
|
||||
}
|
||||
operations += 1;
|
||||
const resolved = await this.#call(
|
||||
"resolve_containing_procedure",
|
||||
{ address: source },
|
||||
signal,
|
||||
);
|
||||
if (!resolved.ok) return resolved;
|
||||
references.push({
|
||||
target_address: match.address,
|
||||
source_address: source,
|
||||
containing_procedure: resolved.value,
|
||||
});
|
||||
if (references.length >= limit) {
|
||||
residual.add("Reference results reached the configured limit.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (references.length >= limit) break;
|
||||
}
|
||||
return ok({
|
||||
references,
|
||||
operations,
|
||||
residual: [...residual],
|
||||
});
|
||||
}
|
||||
|
||||
async #allAddressed(
|
||||
tool: "list_names" | "list_procedures",
|
||||
signal?: AbortSignal,
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { JavaScriptExportShapeComparisonResult } from "../domain/javascript
|
||||
import type { ApplicationFeatureTraceResult } from "../domain/javascriptFeatureTraceSchemas.js";
|
||||
import type { JavaScriptSemanticTraceResult } from "../domain/javascriptSemanticTraceSchemas.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import type { SourceToBundleComparisonResult } from "../domain/sourceToBundleComparisonSchemas.js";
|
||||
import { JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER } from "./InvestigationProviders.js";
|
||||
|
||||
/** Create derived Evidence for one bounded application-graph feature trace. */
|
||||
@@ -64,6 +65,24 @@ export const createApplicationVersionComparisonEvidence = (
|
||||
evidenceLinks: result.evidence_links,
|
||||
});
|
||||
|
||||
/** Create derived Evidence for one historical-source to bundle comparison. */
|
||||
export const createSourceToBundleComparisonEvidence = (
|
||||
parameters: EvidenceObservation["parameters"],
|
||||
result: SourceToBundleComparisonResult,
|
||||
): Evidence =>
|
||||
createEvidence(undefined, JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER, {
|
||||
predicateType: "rea.source-to-bundle-comparison/v1",
|
||||
operation: "compare_source_to_bundle",
|
||||
parameters,
|
||||
result: jsonValueSchema.parse(result),
|
||||
rawResult: null,
|
||||
confidence: "inferred",
|
||||
authority: "analyst-inference",
|
||||
environment: null,
|
||||
limitations: result.limitations,
|
||||
evidenceLinks: result.evidence_links,
|
||||
});
|
||||
|
||||
/** Create derived Evidence for one bounded static export-shape comparison. */
|
||||
export const createJavaScriptExportShapeComparisonEvidence = (
|
||||
parameters: EvidenceObservation["parameters"],
|
||||
|
||||
@@ -14,6 +14,8 @@ import { compareJavaScriptExportShapesInputSchema } from "../domain/javascriptEx
|
||||
import { traceApplicationFeature } from "../domain/javascriptFeatureTrace.js";
|
||||
import { traceApplicationFeatureInputSchema } from "../domain/javascriptFeatureTraceSchemas.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { compareSourceToBundle } from "../domain/sourceToBundleComparison.js";
|
||||
import { compareSourceToBundleInputSchema } from "../domain/sourceToBundleComparisonSchemas.js";
|
||||
import {
|
||||
parseApplicationGraphEvidence,
|
||||
parseNativeApplicationEvidence,
|
||||
@@ -22,6 +24,7 @@ import {
|
||||
createApplicationFeatureTraceEvidence,
|
||||
createApplicationVersionComparisonEvidence,
|
||||
createJavaScriptExportShapeComparisonEvidence,
|
||||
createSourceToBundleComparisonEvidence,
|
||||
} from "./JavaScriptApplicationWorkflowEvidence.js";
|
||||
|
||||
/** Authenticate Evidence and derive one bounded cross-layer feature trace. */
|
||||
@@ -141,6 +144,37 @@ export const compareApplicationVersionsEvidenceValidated = (
|
||||
}
|
||||
};
|
||||
|
||||
/** Compare source and bundle inputs already parsed by a trusted adapter. */
|
||||
export const compareSourceToBundleEvidenceValidated = (
|
||||
input: z.output<typeof compareSourceToBundleInputSchema>,
|
||||
): Result<Evidence, AnalysisError> => {
|
||||
const operation = "compare_source_to_bundle";
|
||||
try {
|
||||
const application = parseApplicationGraphEvidence(input.application);
|
||||
const result = compareSourceToBundle({
|
||||
reference: input.reference,
|
||||
application: {
|
||||
evidenceId: application.evidence.evidence_id,
|
||||
rootArtifactSha256: application.rootArtifactSha256,
|
||||
graph: application.graph,
|
||||
},
|
||||
limits: input.limits,
|
||||
});
|
||||
return ok(
|
||||
createSourceToBundleComparisonEvidence(
|
||||
{
|
||||
reference_root_sha256: input.reference.root_sha256,
|
||||
application_evidence_id: application.evidence.evidence_id,
|
||||
limits: input.limits,
|
||||
},
|
||||
result,
|
||||
),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return workflowFailure(operation, cause);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Authenticate both graphs and compare one exact export return shape.
|
||||
* @public
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { ProviderIdentity } from "./AnalysisProvider.js";
|
||||
import {
|
||||
createEvidence,
|
||||
type Evidence,
|
||||
type EvidenceObservation,
|
||||
} from "../domain/evidence.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import type {
|
||||
JavaScriptRuntimeObservation,
|
||||
JavaScriptRuntimeTargetList,
|
||||
ListJavaScriptRuntimeTargetsInput,
|
||||
ObserveJavaScriptRuntimeInput,
|
||||
} from "../domain/javascriptRuntimeObservation.js";
|
||||
|
||||
type RuntimeObservationOperation =
|
||||
| "list_javascript_runtime_targets"
|
||||
| "observe_javascript_runtime";
|
||||
|
||||
/** Create deterministic Evidence v2 for one passive Inspector operation. */
|
||||
export const createJavaScriptRuntimeObservationEvidence = (
|
||||
operation: RuntimeObservationOperation,
|
||||
input: ListJavaScriptRuntimeTargetsInput | ObserveJavaScriptRuntimeInput,
|
||||
result: JavaScriptRuntimeTargetList | JavaScriptRuntimeObservation,
|
||||
provider: ProviderIdentity,
|
||||
): Evidence =>
|
||||
createEvidence(undefined, provider, {
|
||||
predicateType:
|
||||
operation === "list_javascript_runtime_targets"
|
||||
? "rea.javascript-runtime-target-list/v1"
|
||||
: "rea.javascript-runtime-observation/v1",
|
||||
operation,
|
||||
parameters: parameters(input),
|
||||
result: jsonValueSchema.parse(result),
|
||||
rawResult: null,
|
||||
confidence: "observed",
|
||||
authority: "external-service",
|
||||
environment: {
|
||||
id: `${result.runtime.product}@${result.runtime.protocol_version}`,
|
||||
platform: process.platform,
|
||||
architecture: process.arch,
|
||||
isolation: "none",
|
||||
},
|
||||
limitations: result.limitations,
|
||||
});
|
||||
|
||||
const parameters = (
|
||||
input: ListJavaScriptRuntimeTargetsInput | ObserveJavaScriptRuntimeInput,
|
||||
): EvidenceObservation["parameters"] => ({
|
||||
inspector_endpoint: input.inspector_endpoint,
|
||||
allowed_file_roots: input.allowed_file_roots,
|
||||
allowed_origins: input.allowed_origins,
|
||||
...("target_id" in input
|
||||
? {
|
||||
target_id: input.target_id,
|
||||
runtime_kind: input.runtime_kind,
|
||||
observation_ms: input.observation_ms,
|
||||
limits: input.limits,
|
||||
}
|
||||
: { offset: input.offset, limit: input.limit }),
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ExecutionOptions, ProviderIdentity } from "./AnalysisProvider.js";
|
||||
import type {
|
||||
JavaScriptRuntimeObservation,
|
||||
JavaScriptRuntimeTargetList,
|
||||
ListJavaScriptRuntimeTargetsInput,
|
||||
ObserveJavaScriptRuntimeInput,
|
||||
} from "../domain/javascriptRuntimeObservation.js";
|
||||
import type { AnalysisError } from "../domain/errors.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
|
||||
/** Provider-neutral boundary for passive Node/Electron Inspector observation. */
|
||||
export interface JavaScriptRuntimeObservationPort {
|
||||
identity(): ProviderIdentity;
|
||||
listTargets(
|
||||
input: ListJavaScriptRuntimeTargetsInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<JavaScriptRuntimeTargetList, AnalysisError>>;
|
||||
observe(
|
||||
input: ObserveJavaScriptRuntimeInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<JavaScriptRuntimeObservation, AnalysisError>>;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { ExecutionOptions } from "./AnalysisProvider.js";
|
||||
import type { JavaScriptRuntimeObservationPort } from "./JavaScriptRuntimeObservationPort.js";
|
||||
import type { PermissionAuthority } from "./PermissionAuthority.js";
|
||||
import { createJavaScriptRuntimeObservationEvidence } from "./JavaScriptRuntimeObservationEvidence.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import {
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisProtocolError,
|
||||
PermissionRequiredError,
|
||||
type AnalysisError,
|
||||
} from "../domain/errors.js";
|
||||
import type {
|
||||
ListJavaScriptRuntimeTargetsInput,
|
||||
ObserveJavaScriptRuntimeInput,
|
||||
} from "../domain/javascriptRuntimeObservation.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
/** Authorize and list attachable Node/Electron Inspector targets. */
|
||||
export const listJavaScriptRuntimeTargets = async (
|
||||
provider: JavaScriptRuntimeObservationPort | undefined,
|
||||
authority: PermissionAuthority | undefined,
|
||||
input: ListJavaScriptRuntimeTargetsInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const ready = await prepare(
|
||||
provider,
|
||||
authority,
|
||||
input,
|
||||
"list_javascript_runtime_targets",
|
||||
);
|
||||
if (!ready.ok) return ready;
|
||||
const result = await ready.value.listTargets(input, options);
|
||||
return result.ok
|
||||
? ok(
|
||||
createJavaScriptRuntimeObservationEvidence(
|
||||
"list_javascript_runtime_targets",
|
||||
input,
|
||||
result.value,
|
||||
ready.value.identity(),
|
||||
),
|
||||
)
|
||||
: result;
|
||||
};
|
||||
|
||||
/** Authorize one bounded attach-only Inspector observation. */
|
||||
export const observeJavaScriptRuntime = async (
|
||||
provider: JavaScriptRuntimeObservationPort | undefined,
|
||||
authority: PermissionAuthority | undefined,
|
||||
input: ObserveJavaScriptRuntimeInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const ready = await prepare(
|
||||
provider,
|
||||
authority,
|
||||
input,
|
||||
"observe_javascript_runtime",
|
||||
);
|
||||
if (!ready.ok) return ready;
|
||||
const result = await ready.value.observe(input, options);
|
||||
return result.ok
|
||||
? ok(
|
||||
createJavaScriptRuntimeObservationEvidence(
|
||||
"observe_javascript_runtime",
|
||||
input,
|
||||
result.value,
|
||||
ready.value.identity(),
|
||||
),
|
||||
)
|
||||
: result;
|
||||
};
|
||||
|
||||
const prepare = async (
|
||||
provider: JavaScriptRuntimeObservationPort | undefined,
|
||||
authority: PermissionAuthority | undefined,
|
||||
input: ListJavaScriptRuntimeTargetsInput | ObserveJavaScriptRuntimeInput,
|
||||
operation: "list_javascript_runtime_targets" | "observe_javascript_runtime",
|
||||
): Promise<Result<JavaScriptRuntimeObservationPort, AnalysisError>> => {
|
||||
if (authority === undefined)
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"rea-v8-inspector",
|
||||
operation,
|
||||
"V8 Inspector observation permission policy is not configured",
|
||||
),
|
||||
);
|
||||
const authorized = await authority.authorize(
|
||||
{
|
||||
capability: "v8_inspector_observe",
|
||||
roots: input.allowed_file_roots,
|
||||
executables: [],
|
||||
environment_names: [],
|
||||
origins: [input.inspector_endpoint, ...input.allowed_origins],
|
||||
network: "loopback",
|
||||
mount: false,
|
||||
operation_identity: `${operation}:${"target_id" in input ? input.target_id : input.inspector_endpoint}`,
|
||||
},
|
||||
"read",
|
||||
);
|
||||
if (!authorized.ok)
|
||||
return err(
|
||||
authorized.error instanceof PermissionRequiredError
|
||||
? authorized.error
|
||||
: new AnalysisProtocolError(authorized.error.message, {
|
||||
cause: authorized.error,
|
||||
}),
|
||||
);
|
||||
return provider === undefined
|
||||
? err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"rea-v8-inspector",
|
||||
operation,
|
||||
"V8 Inspector observation provider is not configured",
|
||||
),
|
||||
)
|
||||
: ok(provider);
|
||||
};
|
||||
@@ -0,0 +1,236 @@
|
||||
import { createJavaScriptSemanticGraphUnknown } from "../domain/javascriptSemanticGraph.js";
|
||||
import type { JavaScriptSemanticGraphNode } from "../domain/javascriptSemanticGraph.js";
|
||||
import type {
|
||||
JavaScriptSemanticEventOperation,
|
||||
JavaScriptSemanticTimerOperation,
|
||||
} from "../domain/javascriptSemanticIr.js";
|
||||
import {
|
||||
addSemanticGraphNode,
|
||||
addSemanticGraphRelation,
|
||||
addSemanticGraphUnknown,
|
||||
constructSemanticGraphNode,
|
||||
} from "./JavaScriptSemanticGraphConstruction.js";
|
||||
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
|
||||
import {
|
||||
semanticCallSiteAt,
|
||||
type SemanticFlowProjectionContext,
|
||||
} from "./JavaScriptSemanticGraphFlowProjection.js";
|
||||
|
||||
/** Project literal EventEmitter registrations, removals, and dispatches. */
|
||||
export const projectSemanticEvents = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
): void => {
|
||||
const eventNodes = new Map<string, JavaScriptSemanticGraphNode>();
|
||||
for (const operation of context.ir.eventOperations) {
|
||||
const eventKey =
|
||||
operation.eventName === null
|
||||
? operation.eventId
|
||||
: `${operation.emitterKey}\u0000${operation.eventName}`;
|
||||
const event =
|
||||
eventNodes.get(eventKey) ?? addEventNode(context, operation, eventKey);
|
||||
if (event === null) continue;
|
||||
eventNodes.set(eventKey, event);
|
||||
const relation = eventRelation(operation);
|
||||
if (operation.kind === "dispatch")
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source: eventEmitterNode(context, operation),
|
||||
target: event,
|
||||
relation,
|
||||
resolution:
|
||||
operation.resolution === "complete" ? "resolved" : "candidate",
|
||||
});
|
||||
else {
|
||||
const listener = addListenerNode(context, operation);
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source: event,
|
||||
target: listener,
|
||||
relation,
|
||||
resolution:
|
||||
operation.resolution === "complete" ? "resolved" : "candidate",
|
||||
});
|
||||
}
|
||||
if (operation.resolution !== "complete")
|
||||
addEventUnknown(context, operation, event, relation);
|
||||
}
|
||||
};
|
||||
|
||||
const addEventNode = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
operation: JavaScriptSemanticEventOperation,
|
||||
eventKey: string,
|
||||
): JavaScriptSemanticGraphNode | null =>
|
||||
addSemanticGraphNode(
|
||||
context.state,
|
||||
constructSemanticGraphNode(
|
||||
context.file,
|
||||
{
|
||||
kind: "event",
|
||||
roleKey: `event:${eventKey}`,
|
||||
location: operation.eventName === null ? operation.location : null,
|
||||
label: operation.eventName,
|
||||
functionNodeId: null,
|
||||
properties: {
|
||||
emitter_key: operation.emitterKey,
|
||||
event_name: operation.eventName,
|
||||
},
|
||||
},
|
||||
context.state,
|
||||
),
|
||||
);
|
||||
|
||||
const addListenerNode = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
operation: JavaScriptSemanticEventOperation,
|
||||
): JavaScriptSemanticGraphNode | null =>
|
||||
addSemanticGraphNode(
|
||||
context.state,
|
||||
constructSemanticGraphNode(
|
||||
context.file,
|
||||
{
|
||||
kind: "listener",
|
||||
roleKey: `listener:${operation.eventId}`,
|
||||
location: operation.listenerLocation ?? operation.location,
|
||||
label:
|
||||
operation.eventName === null
|
||||
? operation.method
|
||||
: `${operation.method}:${operation.eventName}`,
|
||||
functionNodeId:
|
||||
operation.ownerCallableId === null
|
||||
? null
|
||||
: (context.callableNodes.get(operation.ownerCallableId)?.node_id ??
|
||||
null),
|
||||
properties: {
|
||||
emitter_key: operation.emitterKey,
|
||||
event_name: operation.eventName,
|
||||
method: operation.method,
|
||||
},
|
||||
},
|
||||
context.state,
|
||||
),
|
||||
);
|
||||
|
||||
const eventEmitterNode = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
operation: JavaScriptSemanticEventOperation,
|
||||
): JavaScriptSemanticGraphNode =>
|
||||
(operation.emitterBindingId === null
|
||||
? undefined
|
||||
: context.bindingNodes.get(operation.emitterBindingId)) ??
|
||||
(operation.ownerCallableId === null
|
||||
? context.moduleNode
|
||||
: (context.callableNodes.get(operation.ownerCallableId) ??
|
||||
context.moduleNode));
|
||||
|
||||
const eventRelation = (
|
||||
operation: JavaScriptSemanticEventOperation,
|
||||
): "registers-listener" | "removes-listener" | "dispatches-candidate" =>
|
||||
operation.kind === "register"
|
||||
? "registers-listener"
|
||||
: operation.kind === "remove"
|
||||
? "removes-listener"
|
||||
: "dispatches-candidate";
|
||||
|
||||
const addEventUnknown = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
operation: JavaScriptSemanticEventOperation,
|
||||
event: JavaScriptSemanticGraphNode,
|
||||
relation: ReturnType<typeof eventRelation>,
|
||||
): void => {
|
||||
addSemanticGraphUnknown(
|
||||
context.state,
|
||||
createJavaScriptSemanticGraphUnknown({
|
||||
node_id: event.node_id,
|
||||
family: "event",
|
||||
relation_kinds: [relation],
|
||||
reason: "ambiguous-target",
|
||||
detail:
|
||||
operation.eventName === null
|
||||
? `Dynamic ${operation.method} event name remains unresolved.`
|
||||
: `Static ${operation.method} emitter identity is partial.`,
|
||||
candidate_node_ids: [],
|
||||
evidence: unknownSemanticEvidence(context.file, operation.location),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
/** Project Node timer scheduling and exact local handle cancellation. */
|
||||
export const projectSemanticTimers = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
): void => {
|
||||
const timerNodes = new Map(
|
||||
context.ir.timerOperations.flatMap((operation) => {
|
||||
if (operation.kind !== "schedule") return [];
|
||||
const owner =
|
||||
operation.ownerCallableId === null
|
||||
? undefined
|
||||
: context.callableNodes.get(operation.ownerCallableId);
|
||||
const timer = addSemanticGraphNode(
|
||||
context.state,
|
||||
constructSemanticGraphNode(
|
||||
context.file,
|
||||
{
|
||||
kind: "timer",
|
||||
roleKey: operation.timerId,
|
||||
location: operation.location,
|
||||
label: operation.method,
|
||||
functionNodeId: owner?.node_id ?? null,
|
||||
properties: {
|
||||
delay_milliseconds: operation.delayMilliseconds,
|
||||
method: operation.method,
|
||||
},
|
||||
},
|
||||
context.state,
|
||||
),
|
||||
);
|
||||
return timer === null ? [] : [[operation.timerId, timer] as const];
|
||||
}),
|
||||
);
|
||||
for (const operation of context.ir.timerOperations)
|
||||
if (operation.kind === "schedule")
|
||||
projectTimerSchedule(context, operation, timerNodes);
|
||||
else projectTimerCancellation(context, operation, timerNodes);
|
||||
};
|
||||
|
||||
const projectTimerSchedule = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
operation: JavaScriptSemanticTimerOperation,
|
||||
timerNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
|
||||
): void => {
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source: semanticCallSiteAt(context, operation.location),
|
||||
target: timerNodes.get(operation.timerId),
|
||||
relation: "schedules-timer",
|
||||
resolution: "resolved",
|
||||
});
|
||||
};
|
||||
|
||||
const projectTimerCancellation = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
operation: JavaScriptSemanticTimerOperation,
|
||||
timerNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
|
||||
): void => {
|
||||
const callSite = semanticCallSiteAt(context, operation.location);
|
||||
const target =
|
||||
operation.linkedTimerId === null
|
||||
? undefined
|
||||
: timerNodes.get(operation.linkedTimerId);
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source: callSite,
|
||||
target,
|
||||
relation: "cancels-timer",
|
||||
resolution: operation.resolution === "complete" ? "resolved" : "candidate",
|
||||
});
|
||||
if (operation.resolution === "complete") return;
|
||||
addSemanticGraphUnknown(
|
||||
context.state,
|
||||
createJavaScriptSemanticGraphUnknown({
|
||||
node_id: callSite?.node_id ?? null,
|
||||
family: "timer",
|
||||
relation_kinds: ["cancels-timer"],
|
||||
reason: "ambiguous-target",
|
||||
detail: `Static ${operation.method} handle resolution was ${operation.resolution}.`,
|
||||
candidate_node_ids: [],
|
||||
evidence: unknownSemanticEvidence(context.file, operation.location),
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -11,13 +11,29 @@ import type {
|
||||
JavaScriptSemanticCallArgument,
|
||||
JavaScriptSemanticIr,
|
||||
} from "../domain/javascriptSemanticIr.js";
|
||||
import { sourceRangesEqual as rangesEqual } from "../domain/javascriptStaticAnalysisHelpers.js";
|
||||
import type { JavaScriptApplicationGraph } from "../domain/javascriptApplicationGraph.js";
|
||||
import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
|
||||
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
|
||||
import {
|
||||
projectSemanticEvents,
|
||||
projectSemanticTimers,
|
||||
} from "./JavaScriptSemanticGraphAsyncProjection.js";
|
||||
import { projectSemanticChildProcesses } from "./JavaScriptSemanticGraphChildProcessProjection.js";
|
||||
import {
|
||||
projectSemanticBoundaries,
|
||||
projectSemanticConfiguration,
|
||||
projectSemanticRequests,
|
||||
} from "./JavaScriptSemanticGraphDataProjection.js";
|
||||
import { projectSemanticResources } from "./JavaScriptSemanticGraphResourceProjection.js";
|
||||
import { projectSemanticObjects } from "./JavaScriptSemanticGraphObjectProjection.js";
|
||||
import { projectSemanticValues } from "./JavaScriptSemanticGraphValueProjection.js";
|
||||
import { inferredSemanticEvidenceAt } from "./JavaScriptSemanticGraphEvidence.js";
|
||||
import { projectSemanticFunctionFingerprints } from "./JavaScriptSemanticGraphFingerprintProjection.js";
|
||||
import {
|
||||
projectSemanticClosureCaptures,
|
||||
projectSemanticFrontiers,
|
||||
projectSemanticPromises,
|
||||
projectSemanticReturnValues,
|
||||
semanticRecoveryLimits,
|
||||
type SemanticFlowProjectionContext,
|
||||
@@ -26,7 +42,6 @@ import {
|
||||
owningSemanticCallableNode,
|
||||
semanticFamilyStatus,
|
||||
semanticNodesWithinRange,
|
||||
semanticRangesEqual as rangesEqual,
|
||||
} from "./JavaScriptSemanticGraphProjection.js";
|
||||
import {
|
||||
addSemanticFallbackRoot as addFallbackRoot,
|
||||
@@ -71,9 +86,12 @@ export const buildJavaScriptSemanticGraph = ({
|
||||
analysis,
|
||||
}: BuilderInput): JavaScriptSemanticGraph => {
|
||||
const state = emptyState(applicationGraph);
|
||||
const fingerprints: JavaScriptSemanticGraph["fingerprints"][number][] = [];
|
||||
for (const analyzed of analysis.files) {
|
||||
if (analyzed.semantic === null) continue;
|
||||
projectFile(analyzed.file, analyzed.semantic.ir, state);
|
||||
fingerprints.push(
|
||||
...projectFile(analyzed.file, analyzed.semantic.ir, state),
|
||||
);
|
||||
}
|
||||
if (state.roots.size === 0) addFallbackRoot(rootArtifactSha256, state);
|
||||
const semanticTruncated = analysis.files.some(
|
||||
@@ -89,7 +107,7 @@ export const buildJavaScriptSemanticGraph = ({
|
||||
root_node_ids: [...state.roots],
|
||||
nodes: [...state.nodes.values()],
|
||||
relations: [...state.relations.values()],
|
||||
fingerprints: [],
|
||||
fingerprints,
|
||||
unknowns,
|
||||
coverage: {
|
||||
status: truncated ? "partial" : "unknown",
|
||||
@@ -135,7 +153,15 @@ export const buildJavaScriptSemanticGraph = ({
|
||||
limitations: [
|
||||
"The semantic graph contains static syntax observations and conservative relationship candidates; it does not claim runtime execution.",
|
||||
"Local data flow does not claim control-flow-sensitive reaching definitions or arbitrary dynamic property resolution.",
|
||||
"Function fingerprints and promise, event, process, request, boundary, timer, configuration, and resource extraction are not available in this graph version's current extractor coverage.",
|
||||
"Promise ownership covers explicit unshadowed Promise construction, static factories, aggregation, chaining, and await syntax only.",
|
||||
"Function fingerprints are bounded static candidates; equal digests can remain ambiguous and do not prove behavioral equivalence.",
|
||||
"Event extraction covers EventEmitter-style literal registrations, removals, and dispatch candidates; dynamic names remain unknown.",
|
||||
"Timer extraction covers global or node:timers scheduling and exact local-handle cancellation.",
|
||||
"Child-process extraction covers asynchronous node:child_process creation, literal argv/env/stdio options, exit/error listeners, and kill signals.",
|
||||
"Configuration extraction covers process.env, process.argv, node:fs reads, and direct logical defaults.",
|
||||
"Request extraction covers fetch, WebSocket, node:http/node:https construction, direct option fields, and exact local response consumers.",
|
||||
"Boundary extraction covers unshadowed JSON/global coercions plus parse and validation method candidates.",
|
||||
"Resource extraction covers built-in filesystem/network acquisition and exact local close/destroy/end handles.",
|
||||
...(truncated
|
||||
? [
|
||||
"Semantic graph projection reached one or more explicit hard limits.",
|
||||
@@ -149,7 +175,7 @@ const projectFile = (
|
||||
file: JavaScriptArtifactFile,
|
||||
ir: JavaScriptSemanticIr,
|
||||
state: BuilderState,
|
||||
): void => {
|
||||
): JavaScriptSemanticGraph["fingerprints"][number][] => {
|
||||
const moduleNode = addNode(
|
||||
state,
|
||||
semanticNode(
|
||||
@@ -164,7 +190,7 @@ const projectFile = (
|
||||
state,
|
||||
),
|
||||
);
|
||||
if (moduleNode === null) return;
|
||||
if (moduleNode === null) return [];
|
||||
state.roots.add(moduleNode.node_id);
|
||||
const callableNodes = new Map(
|
||||
ir.callables.flatMap((callable) => {
|
||||
@@ -228,9 +254,20 @@ const projectFile = (
|
||||
),
|
||||
};
|
||||
projectDefinitionsAndReferences(context);
|
||||
projectSemanticValues(context);
|
||||
projectSemanticObjects(context);
|
||||
projectCalls(context);
|
||||
projectSemanticPromises(context);
|
||||
projectSemanticEvents(context);
|
||||
projectSemanticTimers(context);
|
||||
projectSemanticChildProcesses(context);
|
||||
projectSemanticConfiguration(context);
|
||||
projectSemanticRequests(context);
|
||||
projectSemanticBoundaries(context);
|
||||
projectSemanticResources(context);
|
||||
projectSemanticClosureCaptures(context);
|
||||
projectSemanticFrontiers(context);
|
||||
return projectSemanticFunctionFingerprints(file, ir, callableNodes);
|
||||
};
|
||||
|
||||
const createReturnSiteNodes = (
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import { createJavaScriptSemanticGraphUnknown } from "../domain/javascriptSemanticGraph.js";
|
||||
import type { JavaScriptSemanticGraphNode } from "../domain/javascriptSemanticGraph.js";
|
||||
import type {
|
||||
JavaScriptSemanticChildProcessInteraction,
|
||||
JavaScriptSemanticChildProcessSpawn,
|
||||
} from "../domain/javascriptSemanticIr.js";
|
||||
import {
|
||||
addSemanticGraphNode,
|
||||
addSemanticGraphRelation,
|
||||
addSemanticGraphUnknown,
|
||||
constructSemanticGraphNode,
|
||||
} from "./JavaScriptSemanticGraphConstruction.js";
|
||||
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
|
||||
import {
|
||||
semanticCallSiteAt,
|
||||
type SemanticFlowProjectionContext,
|
||||
} from "./JavaScriptSemanticGraphFlowProjection.js";
|
||||
|
||||
interface ChildUnknownInput {
|
||||
readonly context: SemanticFlowProjectionContext;
|
||||
readonly node: JavaScriptSemanticGraphNode | null;
|
||||
readonly location: JavaScriptSemanticChildProcessSpawn["location"];
|
||||
readonly relationKinds: readonly (
|
||||
| "spawns"
|
||||
| "forwards-signal"
|
||||
| "listens-exit"
|
||||
| "listens-error"
|
||||
)[];
|
||||
readonly detail: string;
|
||||
readonly candidateNodeIds: readonly string[];
|
||||
}
|
||||
|
||||
/** Project child creation, argv/env/stdio, listeners, and signals. */
|
||||
export const projectSemanticChildProcesses = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
): void => {
|
||||
const processNodes = new Map(
|
||||
context.ir.childProcessSpawns.flatMap((spawn) => {
|
||||
const node = addChildProcessNode(context, spawn);
|
||||
return node === null ? [] : [[spawn.processId, node] as const];
|
||||
}),
|
||||
);
|
||||
for (const spawn of context.ir.childProcessSpawns) {
|
||||
const child = processNodes.get(spawn.processId);
|
||||
if (child === undefined) continue;
|
||||
projectSpawn(context, spawn, child);
|
||||
}
|
||||
for (const interaction of context.ir.childProcessInteractions)
|
||||
projectInteraction(context, interaction, processNodes);
|
||||
};
|
||||
|
||||
const addChildProcessNode = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
spawn: JavaScriptSemanticChildProcessSpawn,
|
||||
): JavaScriptSemanticGraphNode | null =>
|
||||
addSemanticGraphNode(
|
||||
context.state,
|
||||
constructSemanticGraphNode(
|
||||
context.file,
|
||||
{
|
||||
kind: "child-process",
|
||||
roleKey: spawn.processId,
|
||||
location: spawn.location,
|
||||
label: spawn.command ?? spawn.method,
|
||||
functionNodeId:
|
||||
spawn.ownerCallableId === null
|
||||
? null
|
||||
: (context.callableNodes.get(spawn.ownerCallableId)?.node_id ??
|
||||
null),
|
||||
properties: {
|
||||
argv_count: spawn.argvCount,
|
||||
command: spawn.command,
|
||||
environment_supplied: spawn.environmentSupplied,
|
||||
method: spawn.method,
|
||||
stdio_mode: spawn.stdioMode,
|
||||
},
|
||||
},
|
||||
context.state,
|
||||
),
|
||||
);
|
||||
|
||||
const projectSpawn = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
spawn: JavaScriptSemanticChildProcessSpawn,
|
||||
child: JavaScriptSemanticGraphNode,
|
||||
): void => {
|
||||
const callSite = semanticCallSiteAt(context, spawn.location);
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source: callSite,
|
||||
target: child,
|
||||
relation: "spawns",
|
||||
resolution: spawn.resolution === "complete" ? "resolved" : "candidate",
|
||||
});
|
||||
if (spawn.argvCount !== null)
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source: callSite,
|
||||
target: child,
|
||||
relation: "supplies-argv",
|
||||
resolution: "resolved",
|
||||
properties: { argument_count: spawn.argvCount },
|
||||
});
|
||||
if (spawn.environmentSupplied)
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source: callSite,
|
||||
target: child,
|
||||
relation: "supplies-env",
|
||||
resolution: "resolved",
|
||||
});
|
||||
const stdio = addStdioNode(context, spawn, child);
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source: child,
|
||||
target: stdio,
|
||||
relation: "connects-stdio",
|
||||
resolution: spawn.stdioMode === "dynamic" ? "candidate" : "resolved",
|
||||
});
|
||||
if (spawn.resolution !== "complete")
|
||||
addChildUnknown({
|
||||
context,
|
||||
node: child,
|
||||
location: spawn.location,
|
||||
relationKinds: ["spawns"],
|
||||
detail: `Static ${spawn.method} command remains unresolved.`,
|
||||
candidateNodeIds: [],
|
||||
});
|
||||
};
|
||||
|
||||
const addStdioNode = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
spawn: JavaScriptSemanticChildProcessSpawn,
|
||||
child: JavaScriptSemanticGraphNode,
|
||||
): JavaScriptSemanticGraphNode | null =>
|
||||
addSemanticGraphNode(
|
||||
context.state,
|
||||
constructSemanticGraphNode(
|
||||
context.file,
|
||||
{
|
||||
kind: "stdio",
|
||||
roleKey: `stdio:${spawn.processId}`,
|
||||
location: spawn.location,
|
||||
label: spawn.stdioMode,
|
||||
functionNodeId: child.function_node_id,
|
||||
properties: { mode: spawn.stdioMode },
|
||||
},
|
||||
context.state,
|
||||
),
|
||||
);
|
||||
|
||||
const projectInteraction = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
interaction: JavaScriptSemanticChildProcessInteraction,
|
||||
processNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
|
||||
): void => {
|
||||
const children = interaction.linkedProcessIds.flatMap((identifier) => {
|
||||
const child = processNodes.get(identifier);
|
||||
return child === undefined ? [] : [child];
|
||||
});
|
||||
const effect =
|
||||
interaction.kind === "listener"
|
||||
? addChildListenerNode(context, interaction)
|
||||
: addSignalNode(context, interaction);
|
||||
for (const child of children)
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source: interaction.kind === "listener" ? child : effect,
|
||||
target: interaction.kind === "listener" ? effect : child,
|
||||
relation:
|
||||
interaction.kind === "signal"
|
||||
? "forwards-signal"
|
||||
: interaction.eventName === "exit"
|
||||
? "listens-exit"
|
||||
: "listens-error",
|
||||
resolution:
|
||||
interaction.resolution === "complete" ? "resolved" : "candidate",
|
||||
});
|
||||
if (interaction.resolution !== "complete")
|
||||
addChildUnknown({
|
||||
context,
|
||||
node: effect,
|
||||
location: interaction.location,
|
||||
relationKinds: [
|
||||
interaction.kind === "signal"
|
||||
? "forwards-signal"
|
||||
: interaction.eventName === "exit"
|
||||
? "listens-exit"
|
||||
: "listens-error",
|
||||
],
|
||||
detail: `Static child ${interaction.method} ownership is ambiguous.`,
|
||||
candidateNodeIds: children.map(({ node_id: identifier }) => identifier),
|
||||
});
|
||||
};
|
||||
|
||||
const addChildListenerNode = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
interaction: JavaScriptSemanticChildProcessInteraction,
|
||||
): JavaScriptSemanticGraphNode | null =>
|
||||
addSemanticGraphNode(
|
||||
context.state,
|
||||
constructSemanticGraphNode(
|
||||
context.file,
|
||||
{
|
||||
kind: "listener",
|
||||
roleKey: interaction.interactionId,
|
||||
location: interaction.listenerLocation ?? interaction.location,
|
||||
label: interaction.eventName,
|
||||
functionNodeId:
|
||||
interaction.ownerCallableId === null
|
||||
? null
|
||||
: (context.callableNodes.get(interaction.ownerCallableId)
|
||||
?.node_id ?? null),
|
||||
properties: {
|
||||
event_name: interaction.eventName,
|
||||
method: interaction.method,
|
||||
},
|
||||
},
|
||||
context.state,
|
||||
),
|
||||
);
|
||||
|
||||
const addSignalNode = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
interaction: JavaScriptSemanticChildProcessInteraction,
|
||||
): JavaScriptSemanticGraphNode | null =>
|
||||
addSemanticGraphNode(
|
||||
context.state,
|
||||
constructSemanticGraphNode(
|
||||
context.file,
|
||||
{
|
||||
kind: "signal",
|
||||
roleKey: interaction.interactionId,
|
||||
location: interaction.location,
|
||||
label: interaction.signalName,
|
||||
functionNodeId:
|
||||
interaction.ownerCallableId === null
|
||||
? null
|
||||
: (context.callableNodes.get(interaction.ownerCallableId)
|
||||
?.node_id ?? null),
|
||||
properties: { signal_name: interaction.signalName },
|
||||
},
|
||||
context.state,
|
||||
),
|
||||
);
|
||||
|
||||
const addChildUnknown = (input: ChildUnknownInput): void => {
|
||||
const { context, node, location, relationKinds, detail, candidateNodeIds } =
|
||||
input;
|
||||
addSemanticGraphUnknown(
|
||||
context.state,
|
||||
createJavaScriptSemanticGraphUnknown({
|
||||
node_id: node?.node_id ?? null,
|
||||
family: "child-process",
|
||||
relation_kinds: relationKinds,
|
||||
reason: "ambiguous-target",
|
||||
detail,
|
||||
candidate_node_ids: candidateNodeIds,
|
||||
evidence: unknownSemanticEvidence(context.file, location),
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { JavaScriptApplicationGraph } from "../domain/javascriptApplicationGraph.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import {
|
||||
createJavaScriptSemanticGraphNode,
|
||||
createJavaScriptSemanticGraphRelation,
|
||||
@@ -49,6 +50,7 @@ export interface SemanticNodeConstructionInput {
|
||||
readonly location: JavaScriptSourceRange | null;
|
||||
readonly label: string | null;
|
||||
readonly functionNodeId: string | null;
|
||||
readonly properties?: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
|
||||
/** Input for one directed static semantic relationship. */
|
||||
@@ -58,6 +60,7 @@ export interface SemanticRelationConstructionInput {
|
||||
readonly relation: JavaScriptSemanticGraphRelation["relation"];
|
||||
readonly resolution?: JavaScriptSemanticGraphRelation["resolution"];
|
||||
readonly evidence?: ApplicationGraphEvidence;
|
||||
readonly properties?: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
|
||||
/** Create empty projection state with structural application-node mappings. */
|
||||
@@ -94,7 +97,7 @@ export const constructSemanticGraphNode = (
|
||||
function_node_id: input.functionNodeId,
|
||||
application_node_ids: matchingApplicationNodeIds(file, input, state),
|
||||
label: input.label,
|
||||
properties: {},
|
||||
properties: input.properties ?? {},
|
||||
evidence: observedSemanticEvidence(file, input.location),
|
||||
});
|
||||
|
||||
@@ -131,7 +134,7 @@ export const addSemanticGraphRelation = (
|
||||
target_node_id: input.target.node_id,
|
||||
relation: input.relation,
|
||||
resolution: input.resolution ?? "candidate",
|
||||
properties: {},
|
||||
properties: input.properties ?? {},
|
||||
evidence: input.evidence ?? inferredSemanticEvidence(input.source),
|
||||
});
|
||||
if (state.relations.has(value.relation_id)) return;
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
import { createJavaScriptSemanticGraphUnknown } from "../domain/javascriptSemanticGraph.js";
|
||||
import type { JavaScriptSemanticGraphNode } from "../domain/javascriptSemanticGraph.js";
|
||||
import type {
|
||||
JavaScriptSemanticBoundaryOperation,
|
||||
JavaScriptSemanticConfigurationOperation,
|
||||
JavaScriptSemanticRequestOperation,
|
||||
} from "../domain/javascriptSemanticIr.js";
|
||||
import {
|
||||
addSemanticGraphNode,
|
||||
addSemanticGraphRelation,
|
||||
addSemanticGraphUnknown,
|
||||
constructSemanticGraphNode,
|
||||
} from "./JavaScriptSemanticGraphConstruction.js";
|
||||
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
|
||||
import {
|
||||
semanticCallSiteAt,
|
||||
type SemanticFlowProjectionContext,
|
||||
} from "./JavaScriptSemanticGraphFlowProjection.js";
|
||||
|
||||
/** Project environment, argv, file, default, and precedence candidates. */
|
||||
export const projectSemanticConfiguration = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
): void => {
|
||||
const configNodes = new Map(
|
||||
context.ir.configurationOperations.flatMap((operation) => {
|
||||
const node = addConfigurationNode(context, operation);
|
||||
return node === null ? [] : [[operation.configId, node] as const];
|
||||
}),
|
||||
);
|
||||
for (const operation of context.ir.configurationOperations) {
|
||||
const config = configNodes.get(operation.configId);
|
||||
if (config === undefined) continue;
|
||||
const target = bindingOrOwner(
|
||||
context,
|
||||
operation.resultBindingId,
|
||||
operation.ownerCallableId,
|
||||
);
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source: config,
|
||||
target,
|
||||
relation: configurationRelation(operation),
|
||||
resolution:
|
||||
operation.resolution === "complete" ? "resolved" : "candidate",
|
||||
});
|
||||
if (operation.kind === "default" && operation.sourceConfigId !== null)
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source: configNodes.get(operation.sourceConfigId),
|
||||
target: config,
|
||||
relation: "overrides",
|
||||
resolution:
|
||||
operation.resolution === "complete" ? "resolved" : "candidate",
|
||||
});
|
||||
if (operation.resolution !== "complete")
|
||||
addDataUnknown({
|
||||
context,
|
||||
node: config,
|
||||
family: "configuration",
|
||||
relationKinds: [
|
||||
configurationRelation(operation),
|
||||
...(operation.kind === "default" ? (["overrides"] as const) : []),
|
||||
],
|
||||
location: operation.location,
|
||||
detail: `Static ${operation.kind} configuration identity is partial.`,
|
||||
candidateNodeIds: [],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addConfigurationNode = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
operation: JavaScriptSemanticConfigurationOperation,
|
||||
): JavaScriptSemanticGraphNode | null =>
|
||||
addSemanticGraphNode(
|
||||
context.state,
|
||||
constructSemanticGraphNode(
|
||||
context.file,
|
||||
{
|
||||
kind: "config-source",
|
||||
roleKey: operation.configId,
|
||||
location: operation.location,
|
||||
label: operation.key ?? operation.kind,
|
||||
functionNodeId:
|
||||
operation.ownerCallableId === null
|
||||
? null
|
||||
: (context.callableNodes.get(operation.ownerCallableId)?.node_id ??
|
||||
null),
|
||||
properties: {
|
||||
key: operation.key,
|
||||
source_kind: operation.kind,
|
||||
value: operation.value,
|
||||
},
|
||||
},
|
||||
context.state,
|
||||
),
|
||||
);
|
||||
|
||||
const configurationRelation = (
|
||||
operation: JavaScriptSemanticConfigurationOperation,
|
||||
): "reads-environment" | "reads-argv" | "reads-config" | "defaults" =>
|
||||
operation.kind === "environment"
|
||||
? "reads-environment"
|
||||
: operation.kind === "argv"
|
||||
? "reads-argv"
|
||||
: operation.kind === "file"
|
||||
? "reads-config"
|
||||
: "defaults";
|
||||
|
||||
/** Project request construction, field provenance, and response consumers. */
|
||||
export const projectSemanticRequests = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
): void => {
|
||||
const requestNodes = new Map(
|
||||
context.ir.requestOperations.flatMap((operation) => {
|
||||
const node = addRequestNode(context, operation);
|
||||
return node === null ? [] : [[operation.requestId, node] as const];
|
||||
}),
|
||||
);
|
||||
for (const operation of context.ir.requestOperations) {
|
||||
const request = requestNodes.get(operation.requestId);
|
||||
if (request === undefined) continue;
|
||||
if (operation.kind === "request")
|
||||
projectRequestConstruction(context, operation, request);
|
||||
else projectResponseConsumer(context, operation, request);
|
||||
if (operation.resolution !== "complete")
|
||||
addDataUnknown({
|
||||
context,
|
||||
node: request,
|
||||
family: "request",
|
||||
relationKinds:
|
||||
operation.kind === "request"
|
||||
? ["constructs-request", "supplies-request-field"]
|
||||
: ["consumed-by"],
|
||||
location: operation.location,
|
||||
detail: `Static ${operation.method} request resolution was ${operation.resolution}.`,
|
||||
candidateNodeIds: operation.linkedRequestIds.flatMap((identifier) => {
|
||||
const candidate = requestNodes.get(identifier);
|
||||
return candidate === undefined ? [] : [candidate.node_id];
|
||||
}),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addRequestNode = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
operation: JavaScriptSemanticRequestOperation,
|
||||
): JavaScriptSemanticGraphNode | null =>
|
||||
addSemanticGraphNode(
|
||||
context.state,
|
||||
constructSemanticGraphNode(
|
||||
context.file,
|
||||
{
|
||||
kind: operation.kind === "request" ? "request" : "response",
|
||||
roleKey: operation.requestId,
|
||||
location: operation.location,
|
||||
label: operation.endpoint ?? operation.method,
|
||||
functionNodeId:
|
||||
operation.ownerCallableId === null
|
||||
? null
|
||||
: (context.callableNodes.get(operation.ownerCallableId)?.node_id ??
|
||||
null),
|
||||
properties: {
|
||||
endpoint: operation.endpoint,
|
||||
method: operation.method,
|
||||
operation_kind: operation.kind,
|
||||
},
|
||||
},
|
||||
context.state,
|
||||
),
|
||||
);
|
||||
|
||||
const projectRequestConstruction = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
operation: JavaScriptSemanticRequestOperation,
|
||||
request: JavaScriptSemanticGraphNode,
|
||||
): void => {
|
||||
const callSite = semanticCallSiteAt(context, operation.location);
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source: callSite,
|
||||
target: request,
|
||||
relation: "constructs-request",
|
||||
resolution: operation.resolution === "complete" ? "resolved" : "candidate",
|
||||
});
|
||||
for (const field of operation.fields)
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source:
|
||||
field.sourceBindingId === null
|
||||
? callSite
|
||||
: context.bindingNodes.get(field.sourceBindingId),
|
||||
target: request,
|
||||
relation: "supplies-request-field",
|
||||
resolution: field.sourceBindingId === null ? "candidate" : "resolved",
|
||||
properties: { field: field.name },
|
||||
});
|
||||
};
|
||||
|
||||
const projectResponseConsumer = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
operation: JavaScriptSemanticRequestOperation,
|
||||
response: JavaScriptSemanticGraphNode,
|
||||
): void => {
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source: response,
|
||||
target: semanticCallSiteAt(context, operation.location),
|
||||
relation: "consumed-by",
|
||||
resolution: operation.resolution === "complete" ? "resolved" : "candidate",
|
||||
properties: { method: operation.method },
|
||||
});
|
||||
};
|
||||
|
||||
/** Project parse, coercion, and validation consumers as static boundaries. */
|
||||
export const projectSemanticBoundaries = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
): void => {
|
||||
for (const operation of context.ir.boundaryOperations) {
|
||||
const boundary = addBoundaryNode(context, operation);
|
||||
if (boundary === null) continue;
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source: bindingOrOwner(
|
||||
context,
|
||||
operation.sourceBindingId,
|
||||
operation.ownerCallableId,
|
||||
),
|
||||
target: boundary,
|
||||
relation:
|
||||
operation.kind === "parse"
|
||||
? "parses"
|
||||
: operation.kind === "coerce"
|
||||
? "coerces"
|
||||
: "validates",
|
||||
resolution:
|
||||
operation.resolution === "complete" ? "resolved" : "candidate",
|
||||
});
|
||||
if (operation.resolution !== "complete")
|
||||
addDataUnknown({
|
||||
context,
|
||||
node: boundary,
|
||||
family: "boundary",
|
||||
relationKinds: [
|
||||
operation.kind === "parse"
|
||||
? "parses"
|
||||
: operation.kind === "coerce"
|
||||
? "coerces"
|
||||
: "validates",
|
||||
],
|
||||
location: operation.location,
|
||||
detail: `Static ${operation.method} boundary kind is a candidate.`,
|
||||
candidateNodeIds: [],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addBoundaryNode = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
operation: JavaScriptSemanticBoundaryOperation,
|
||||
): JavaScriptSemanticGraphNode | null =>
|
||||
addSemanticGraphNode(
|
||||
context.state,
|
||||
constructSemanticGraphNode(
|
||||
context.file,
|
||||
{
|
||||
kind: "boundary",
|
||||
roleKey: operation.boundaryId,
|
||||
location: operation.location,
|
||||
label: operation.method,
|
||||
functionNodeId:
|
||||
operation.ownerCallableId === null
|
||||
? null
|
||||
: (context.callableNodes.get(operation.ownerCallableId)?.node_id ??
|
||||
null),
|
||||
properties: {
|
||||
boundary_kind: operation.kind,
|
||||
method: operation.method,
|
||||
},
|
||||
},
|
||||
context.state,
|
||||
),
|
||||
);
|
||||
|
||||
const bindingOrOwner = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
bindingId: string | null,
|
||||
callableId: string | null,
|
||||
): JavaScriptSemanticGraphNode =>
|
||||
(bindingId === null ? undefined : context.bindingNodes.get(bindingId)) ??
|
||||
(callableId === null
|
||||
? context.moduleNode
|
||||
: (context.callableNodes.get(callableId) ?? context.moduleNode));
|
||||
|
||||
interface DataUnknownInput {
|
||||
readonly context: SemanticFlowProjectionContext;
|
||||
readonly node: JavaScriptSemanticGraphNode;
|
||||
readonly family: "boundary" | "configuration" | "request";
|
||||
readonly relationKinds: readonly (
|
||||
| "coerces"
|
||||
| "constructs-request"
|
||||
| "consumed-by"
|
||||
| "defaults"
|
||||
| "overrides"
|
||||
| "parses"
|
||||
| "reads-argv"
|
||||
| "reads-config"
|
||||
| "reads-environment"
|
||||
| "supplies-request-field"
|
||||
| "validates"
|
||||
)[];
|
||||
readonly location: JavaScriptSemanticBoundaryOperation["location"];
|
||||
readonly detail: string;
|
||||
readonly candidateNodeIds: readonly string[];
|
||||
}
|
||||
|
||||
const addDataUnknown = (input: DataUnknownInput): void => {
|
||||
const {
|
||||
context,
|
||||
node,
|
||||
family,
|
||||
relationKinds,
|
||||
location,
|
||||
detail,
|
||||
candidateNodeIds,
|
||||
} = input;
|
||||
addSemanticGraphUnknown(
|
||||
context.state,
|
||||
createJavaScriptSemanticGraphUnknown({
|
||||
node_id: node.node_id,
|
||||
family,
|
||||
relation_kinds: relationKinds,
|
||||
reason: "ambiguous-target",
|
||||
detail,
|
||||
candidate_node_ids: candidateNodeIds,
|
||||
evidence: unknownSemanticEvidence(context.file, location),
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
createJavaScriptSemanticFingerprint,
|
||||
type JavaScriptSemanticGraph,
|
||||
type JavaScriptSemanticGraphNode,
|
||||
} from "../domain/javascriptSemanticGraph.js";
|
||||
import type { JavaScriptSemanticIr } from "../domain/javascriptSemanticIr.js";
|
||||
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
|
||||
import { observedSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
|
||||
|
||||
/** Bind domain fingerprint components to exact semantic function nodes. */
|
||||
export const projectSemanticFunctionFingerprints = (
|
||||
file: JavaScriptArtifactFile,
|
||||
ir: JavaScriptSemanticIr,
|
||||
callableNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
|
||||
): JavaScriptSemanticGraph["fingerprints"][number][] =>
|
||||
ir.functionFingerprints.flatMap((fingerprint) => {
|
||||
const callable = callableNodes.get(fingerprint.callableId);
|
||||
if (callable === undefined) return [];
|
||||
return [
|
||||
createJavaScriptSemanticFingerprint({
|
||||
function_node_id: callable.node_id,
|
||||
algorithm: "rea.javascript-semantic-function/v1",
|
||||
status: fingerprint.status,
|
||||
components: {
|
||||
parameter_arity: fingerprint.components.parameterArity,
|
||||
normalized_ast_sha256: fingerprint.components.normalizedAstSha256,
|
||||
control_flow_sha256: fingerprint.components.controlFlowSha256,
|
||||
relation_shape_sha256: fingerprint.components.relationShapeSha256,
|
||||
literal_set_sha256: fingerprint.components.literalSetSha256,
|
||||
effects: fingerprint.components.effects,
|
||||
},
|
||||
limitations: fingerprint.limitations,
|
||||
evidence: observedSemanticEvidence(
|
||||
file,
|
||||
callable.identity.source_range,
|
||||
),
|
||||
}),
|
||||
];
|
||||
});
|
||||
@@ -1,11 +1,15 @@
|
||||
import { createJavaScriptSemanticGraphUnknown } from "../domain/javascriptSemanticGraph.js";
|
||||
import type { JavaScriptSemanticGraphNode } from "../domain/javascriptSemanticGraph.js";
|
||||
import type { JavaScriptSemanticIr } from "../domain/javascriptSemanticIr.js";
|
||||
import { sourceRangesEqual } from "../domain/javascriptStaticAnalysisHelpers.js";
|
||||
import type { JavaScriptSourceRange } from "../domain/javascriptStaticAnalysisTypes.js";
|
||||
import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
|
||||
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
|
||||
import {
|
||||
addSemanticGraphNode,
|
||||
addSemanticGraphRelation,
|
||||
addSemanticGraphUnknown,
|
||||
constructSemanticGraphNode,
|
||||
type SemanticGraphProjectionState,
|
||||
} from "./JavaScriptSemanticGraphConstruction.js";
|
||||
import {
|
||||
@@ -22,10 +26,191 @@ export interface SemanticFlowProjectionContext {
|
||||
readonly moduleNode: JavaScriptSemanticGraphNode;
|
||||
readonly bindingNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>;
|
||||
readonly callableNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>;
|
||||
readonly callSiteNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>;
|
||||
readonly returnSiteNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>;
|
||||
readonly referenceNodes: readonly JavaScriptSemanticGraphNode[];
|
||||
}
|
||||
|
||||
/** Find the retained call-site node at one exact semantic source range. */
|
||||
export const semanticCallSiteAt = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
location: JavaScriptSourceRange,
|
||||
): JavaScriptSemanticGraphNode | undefined => {
|
||||
const call = context.ir.callSites.find(({ location: candidate }) =>
|
||||
sourceRangesEqual(candidate, location),
|
||||
);
|
||||
return call === undefined
|
||||
? undefined
|
||||
: context.callSiteNodes.get(call.callSiteId);
|
||||
};
|
||||
|
||||
/** Project explicit static Promise/task ownership without runtime claims. */
|
||||
export const projectSemanticPromises = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
): void => {
|
||||
const promiseNodes = new Map(
|
||||
context.ir.promiseOperations.flatMap((operation) => {
|
||||
const owner =
|
||||
operation.ownerCallableId === null
|
||||
? undefined
|
||||
: context.callableNodes.get(operation.ownerCallableId);
|
||||
const node = addSemanticGraphNode(
|
||||
context.state,
|
||||
constructSemanticGraphNode(
|
||||
context.file,
|
||||
{
|
||||
kind: operation.kind === "awaited-expression" ? "task" : "promise",
|
||||
roleKey: operation.promiseId,
|
||||
location: operation.location,
|
||||
label: promiseLabel(operation),
|
||||
functionNodeId: owner?.node_id ?? null,
|
||||
properties: {
|
||||
method: operation.method,
|
||||
operation_kind: operation.kind,
|
||||
ownership: operation.ownership,
|
||||
source_resolution: operation.sourceResolution,
|
||||
},
|
||||
},
|
||||
context.state,
|
||||
),
|
||||
);
|
||||
return node === null ? [] : [[operation.promiseId, node] as const];
|
||||
}),
|
||||
);
|
||||
for (const operation of context.ir.promiseOperations) {
|
||||
const promise = promiseNodes.get(operation.promiseId);
|
||||
if (promise === undefined) continue;
|
||||
projectPromiseCreation(context, operation, promise);
|
||||
projectPromiseSources(context, operation, promise, promiseNodes);
|
||||
projectPromiseOwnership(context, operation, promise);
|
||||
if (
|
||||
operation.sourceResolution !== "complete" &&
|
||||
["chain", "aggregate", "awaited-expression"].includes(operation.kind)
|
||||
)
|
||||
addPromiseUnknown(context, operation, promise, promiseNodes);
|
||||
}
|
||||
};
|
||||
|
||||
const promiseLabel = (
|
||||
operation: JavaScriptSemanticIr["promiseOperations"][number],
|
||||
): string =>
|
||||
operation.kind === "awaited-expression"
|
||||
? "awaited expression"
|
||||
: `Promise.${operation.method}`;
|
||||
|
||||
const projectPromiseCreation = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
operation: JavaScriptSemanticIr["promiseOperations"][number],
|
||||
promise: JavaScriptSemanticGraphNode,
|
||||
): void => {
|
||||
if (operation.kind === "awaited-expression") return;
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source: semanticCallSiteAt(context, operation.location),
|
||||
target: promise,
|
||||
relation: "creates-promise",
|
||||
resolution:
|
||||
operation.kind === "chain" || operation.sourceResolution !== "complete"
|
||||
? "candidate"
|
||||
: "resolved",
|
||||
});
|
||||
};
|
||||
|
||||
const projectPromiseSources = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
operation: JavaScriptSemanticIr["promiseOperations"][number],
|
||||
promise: JavaScriptSemanticGraphNode,
|
||||
promiseNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
|
||||
): void => {
|
||||
for (const sourceId of operation.sourcePromiseIds) {
|
||||
const source = promiseNodes.get(sourceId);
|
||||
if (operation.kind === "aggregate")
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source: promise,
|
||||
target: source,
|
||||
relation: "aggregates",
|
||||
resolution:
|
||||
operation.sourceResolution === "complete" ? "resolved" : "candidate",
|
||||
});
|
||||
else
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source,
|
||||
target: promise,
|
||||
relation: "chains",
|
||||
resolution:
|
||||
operation.sourceResolution === "complete" ? "resolved" : "candidate",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const projectPromiseOwnership = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
operation: JavaScriptSemanticIr["promiseOperations"][number],
|
||||
promise: JavaScriptSemanticGraphNode,
|
||||
): void => {
|
||||
const owner =
|
||||
operation.ownerCallableId === null
|
||||
? context.moduleNode
|
||||
: context.callableNodes.get(operation.ownerCallableId);
|
||||
if (operation.ownership === "awaited")
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source: owner,
|
||||
target: promise,
|
||||
relation: "awaits",
|
||||
resolution: "resolved",
|
||||
});
|
||||
else if (operation.ownership === "assigned")
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source:
|
||||
operation.ownerBindingId === null
|
||||
? undefined
|
||||
: context.bindingNodes.get(operation.ownerBindingId),
|
||||
target: promise,
|
||||
relation: "owns",
|
||||
resolution: "resolved",
|
||||
});
|
||||
else if (operation.ownership === "returned")
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source:
|
||||
operation.returnSiteId === null
|
||||
? undefined
|
||||
: context.returnSiteNodes.get(operation.returnSiteId),
|
||||
target: promise,
|
||||
relation: "returns-task",
|
||||
resolution: "resolved",
|
||||
});
|
||||
else if (operation.ownership === "detached")
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source: owner,
|
||||
target: promise,
|
||||
relation: "detaches-task",
|
||||
resolution: "resolved",
|
||||
});
|
||||
};
|
||||
|
||||
const addPromiseUnknown = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
operation: JavaScriptSemanticIr["promiseOperations"][number],
|
||||
promise: JavaScriptSemanticGraphNode,
|
||||
promiseNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
|
||||
): void => {
|
||||
const relation = operation.kind === "aggregate" ? "aggregates" : "chains";
|
||||
addSemanticGraphUnknown(
|
||||
context.state,
|
||||
createJavaScriptSemanticGraphUnknown({
|
||||
node_id: promise.node_id,
|
||||
family: "promise-ownership",
|
||||
relation_kinds: [relation],
|
||||
reason: "ambiguous-target",
|
||||
detail: `Static ${operation.method} source resolution was ${operation.sourceResolution}.`,
|
||||
candidate_node_ids: operation.sourcePromiseIds.flatMap((identifier) => {
|
||||
const candidate = promiseNodes.get(identifier);
|
||||
return candidate === undefined ? [] : [candidate.node_id];
|
||||
}),
|
||||
evidence: unknownSemanticEvidence(context.file, operation.location),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
/** Link direct return expressions to their retained semantic references. */
|
||||
export const projectSemanticReturnValues = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
@@ -44,7 +229,7 @@ export const projectSemanticReturnValues = (
|
||||
relation: "aliases",
|
||||
resolution:
|
||||
site.identityReferenceLocation !== null &&
|
||||
rangesEqual(
|
||||
sourceRangesEqual(
|
||||
reference.identity.source_range,
|
||||
site.identityReferenceLocation,
|
||||
)
|
||||
@@ -54,17 +239,6 @@ export const projectSemanticReturnValues = (
|
||||
}
|
||||
};
|
||||
|
||||
const rangesEqual = (
|
||||
left: JavaScriptSemanticGraphNode["identity"]["source_range"],
|
||||
right: JavaScriptSemanticGraphNode["identity"]["source_range"],
|
||||
): boolean =>
|
||||
left !== null &&
|
||||
right !== null &&
|
||||
left.start.line === right.start.line &&
|
||||
left.start.column === right.start.column &&
|
||||
left.end.line === right.end.line &&
|
||||
left.end.column === right.end.column;
|
||||
|
||||
/** Link each captured binding to the callable at the exact reference site. */
|
||||
export const projectSemanticClosureCaptures = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { createJavaScriptSemanticGraphUnknown } from "../domain/javascriptSemanticGraph.js";
|
||||
import type { JavaScriptSemanticGraphNode } from "../domain/javascriptSemanticGraph.js";
|
||||
import type { JavaScriptSemanticObjectOperation } from "../domain/javascriptSemanticIr.js";
|
||||
import {
|
||||
addSemanticGraphNode,
|
||||
addSemanticGraphRelation,
|
||||
addSemanticGraphUnknown,
|
||||
constructSemanticGraphNode,
|
||||
} from "./JavaScriptSemanticGraphConstruction.js";
|
||||
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
|
||||
import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlowProjection.js";
|
||||
import { semanticPropertySlot } from "./JavaScriptSemanticGraphValueProjection.js";
|
||||
|
||||
/** Project static object reads, writes, spreads, and destructuring. */
|
||||
export const projectSemanticObjects = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
): void => {
|
||||
for (const operation of context.ir.objectOperations) {
|
||||
const occurrence = addObjectOccurrence(context, operation);
|
||||
const slot =
|
||||
operation.objectBindingId === null || operation.propertyName === null
|
||||
? null
|
||||
: semanticPropertySlot(
|
||||
context,
|
||||
operation.objectBindingId,
|
||||
operation.propertyName,
|
||||
);
|
||||
if (operation.kind === "read")
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source: slot,
|
||||
target: occurrence,
|
||||
relation: "reads-property",
|
||||
resolution:
|
||||
operation.resolution === "complete" ? "resolved" : "candidate",
|
||||
});
|
||||
else if (operation.kind === "write")
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source: occurrence,
|
||||
target: slot,
|
||||
relation: "writes-property",
|
||||
resolution:
|
||||
operation.resolution === "complete" ? "resolved" : "candidate",
|
||||
});
|
||||
else if (operation.kind === "destructure")
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source: slot,
|
||||
target:
|
||||
operation.targetBindingId === null
|
||||
? undefined
|
||||
: context.bindingNodes.get(operation.targetBindingId),
|
||||
relation: "destructures",
|
||||
resolution:
|
||||
operation.resolution === "complete" ? "resolved" : "candidate",
|
||||
});
|
||||
else
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source:
|
||||
operation.objectBindingId === null
|
||||
? undefined
|
||||
: context.bindingNodes.get(operation.objectBindingId),
|
||||
target: occurrence,
|
||||
relation: "spreads",
|
||||
resolution:
|
||||
operation.resolution === "complete" ? "resolved" : "candidate",
|
||||
});
|
||||
if (operation.resolution !== "complete")
|
||||
addObjectUnknown(context, operation, slot ?? occurrence);
|
||||
}
|
||||
};
|
||||
|
||||
const addObjectOccurrence = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
operation: JavaScriptSemanticObjectOperation,
|
||||
): JavaScriptSemanticGraphNode | null =>
|
||||
addSemanticGraphNode(
|
||||
context.state,
|
||||
constructSemanticGraphNode(
|
||||
context.file,
|
||||
{
|
||||
kind: "expression",
|
||||
roleKey: operation.objectOperationId,
|
||||
location: operation.location,
|
||||
label:
|
||||
operation.propertyName === null
|
||||
? operation.kind
|
||||
: `${operation.kind}:${operation.propertyName}`,
|
||||
functionNodeId:
|
||||
operation.ownerCallableId === null
|
||||
? null
|
||||
: (context.callableNodes.get(operation.ownerCallableId)?.node_id ??
|
||||
null),
|
||||
properties: {
|
||||
operation_kind: operation.kind,
|
||||
property_name: operation.propertyName,
|
||||
},
|
||||
},
|
||||
context.state,
|
||||
),
|
||||
);
|
||||
|
||||
const addObjectUnknown = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
operation: JavaScriptSemanticObjectOperation,
|
||||
node: JavaScriptSemanticGraphNode | null,
|
||||
): void => {
|
||||
const relation =
|
||||
operation.kind === "read"
|
||||
? "reads-property"
|
||||
: operation.kind === "write"
|
||||
? "writes-property"
|
||||
: operation.kind === "spread"
|
||||
? "spreads"
|
||||
: "destructures";
|
||||
addSemanticGraphUnknown(
|
||||
context.state,
|
||||
createJavaScriptSemanticGraphUnknown({
|
||||
node_id: node?.node_id ?? null,
|
||||
family: "object-flow",
|
||||
relation_kinds: [relation],
|
||||
reason: "ambiguous-target",
|
||||
detail: `Static ${operation.kind} object identity is partial.`,
|
||||
candidate_node_ids: [],
|
||||
evidence: unknownSemanticEvidence(context.file, operation.location),
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -41,23 +41,27 @@ export const semanticFamilyStatus = (
|
||||
analysis: JavaScriptArtifactAnalysis,
|
||||
truncated: boolean,
|
||||
): "complete" | "partial" | "unknown" | "unsupported" => {
|
||||
if (!["call-flow", "closure", "data-flow", "object-flow"].includes(family))
|
||||
if (
|
||||
![
|
||||
"call-flow",
|
||||
"boundary",
|
||||
"child-process",
|
||||
"closure",
|
||||
"configuration",
|
||||
"data-flow",
|
||||
"event",
|
||||
"object-flow",
|
||||
"promise-ownership",
|
||||
"request",
|
||||
"resource-lifecycle",
|
||||
"timer",
|
||||
].includes(family)
|
||||
)
|
||||
return "unsupported";
|
||||
if (truncated) return "unknown";
|
||||
return analysis.truncated_scopes === 0 ? "partial" : "unknown";
|
||||
};
|
||||
|
||||
/** Compare two exact semantic source ranges. */
|
||||
export const semanticRangesEqual = (
|
||||
left: JavaScriptSourceRange | null,
|
||||
right: JavaScriptSourceRange,
|
||||
): boolean =>
|
||||
left !== null &&
|
||||
left.start.line === right.start.line &&
|
||||
left.start.column === right.start.column &&
|
||||
left.end.line === right.end.line &&
|
||||
left.end.column === right.end.column;
|
||||
|
||||
const contains = (
|
||||
outer: JavaScriptSourceRange,
|
||||
inner: JavaScriptSourceRange,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { createJavaScriptSemanticGraphUnknown } from "../domain/javascriptSemanticGraph.js";
|
||||
import type { JavaScriptSemanticGraphNode } from "../domain/javascriptSemanticGraph.js";
|
||||
import type { JavaScriptSemanticResourceOperation } from "../domain/javascriptSemanticIr.js";
|
||||
import {
|
||||
addSemanticGraphNode,
|
||||
addSemanticGraphRelation,
|
||||
addSemanticGraphUnknown,
|
||||
constructSemanticGraphNode,
|
||||
} from "./JavaScriptSemanticGraphConstruction.js";
|
||||
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
|
||||
import {
|
||||
semanticCallSiteAt,
|
||||
type SemanticFlowProjectionContext,
|
||||
} from "./JavaScriptSemanticGraphFlowProjection.js";
|
||||
|
||||
/** Project built-in resource acquisition and exact local release handles. */
|
||||
export const projectSemanticResources = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
): void => {
|
||||
const resourceNodes = new Map(
|
||||
context.ir.resourceOperations.flatMap((operation) => {
|
||||
if (operation.kind !== "acquire") return [];
|
||||
const node = addResourceNode(context, operation);
|
||||
return node === null ? [] : [[operation.resourceId, node] as const];
|
||||
}),
|
||||
);
|
||||
for (const operation of context.ir.resourceOperations)
|
||||
if (operation.kind === "acquire")
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source: semanticCallSiteAt(context, operation.location),
|
||||
target: resourceNodes.get(operation.resourceId),
|
||||
relation: "acquires",
|
||||
resolution: "resolved",
|
||||
});
|
||||
else projectRelease(context, operation, resourceNodes);
|
||||
};
|
||||
|
||||
const addResourceNode = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
operation: JavaScriptSemanticResourceOperation,
|
||||
): JavaScriptSemanticGraphNode | null =>
|
||||
addSemanticGraphNode(
|
||||
context.state,
|
||||
constructSemanticGraphNode(
|
||||
context.file,
|
||||
{
|
||||
kind: "resource",
|
||||
roleKey: operation.resourceId,
|
||||
location: operation.location,
|
||||
label: operation.method,
|
||||
functionNodeId:
|
||||
operation.ownerCallableId === null
|
||||
? null
|
||||
: (context.callableNodes.get(operation.ownerCallableId)?.node_id ??
|
||||
null),
|
||||
properties: { method: operation.method },
|
||||
},
|
||||
context.state,
|
||||
),
|
||||
);
|
||||
|
||||
const projectRelease = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
operation: JavaScriptSemanticResourceOperation,
|
||||
resourceNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
|
||||
): void => {
|
||||
const callSite = semanticCallSiteAt(context, operation.location);
|
||||
const resources = operation.linkedResourceIds.flatMap((identifier) => {
|
||||
const resource = resourceNodes.get(identifier);
|
||||
return resource === undefined ? [] : [resource];
|
||||
});
|
||||
for (const resource of resources)
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source: callSite,
|
||||
target: resource,
|
||||
relation: "releases",
|
||||
resolution:
|
||||
operation.resolution === "complete" ? "resolved" : "candidate",
|
||||
});
|
||||
if (operation.resolution === "complete") return;
|
||||
addSemanticGraphUnknown(
|
||||
context.state,
|
||||
createJavaScriptSemanticGraphUnknown({
|
||||
node_id: callSite?.node_id ?? null,
|
||||
family: "resource-lifecycle",
|
||||
relation_kinds: ["releases"],
|
||||
reason: "ambiguous-target",
|
||||
detail: `Static ${operation.method} resource ownership is ambiguous.`,
|
||||
candidate_node_ids: resources.map(
|
||||
({ node_id: identifier }) => identifier,
|
||||
),
|
||||
evidence: unknownSemanticEvidence(context.file, operation.location),
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type {
|
||||
JavaScriptSemanticBinding,
|
||||
JavaScriptSemanticValue,
|
||||
} from "../domain/javascriptSemanticIr.js";
|
||||
import {
|
||||
addSemanticGraphNode,
|
||||
addSemanticGraphRelation,
|
||||
constructSemanticGraphNode,
|
||||
} from "./JavaScriptSemanticGraphConstruction.js";
|
||||
import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlowProjection.js";
|
||||
|
||||
/** Project bounded literal values and object slots for exact query seeds. */
|
||||
export const projectSemanticValues = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
): void => {
|
||||
for (const binding of context.ir.bindings) {
|
||||
const bindingNode = context.bindingNodes.get(binding.bindingId);
|
||||
if (bindingNode === undefined) continue;
|
||||
projectValue({
|
||||
context,
|
||||
binding,
|
||||
value: binding.value,
|
||||
target: bindingNode,
|
||||
role: "binding",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
interface ValueProjectionInput {
|
||||
readonly context: SemanticFlowProjectionContext;
|
||||
readonly binding: JavaScriptSemanticBinding;
|
||||
readonly value: JavaScriptSemanticValue;
|
||||
readonly target: ReturnType<typeof semanticPropertySlot>;
|
||||
readonly role: string;
|
||||
}
|
||||
|
||||
const projectValue = (input: ValueProjectionInput): void => {
|
||||
const { context, binding, value, target, role } = input;
|
||||
if (target === null) return;
|
||||
if (value.status === "literal") {
|
||||
const literal = addLiteralNode(context, binding, value.value, role);
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source: literal,
|
||||
target,
|
||||
relation: "defines",
|
||||
resolution: "resolved",
|
||||
});
|
||||
} else if (value.status === "union")
|
||||
for (const primitive of value.values) {
|
||||
const literal = addLiteralNode(context, binding, primitive, role);
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source: literal,
|
||||
target,
|
||||
relation: "defines",
|
||||
resolution: "candidate",
|
||||
});
|
||||
}
|
||||
else if (value.status === "object")
|
||||
for (const property of value.properties) {
|
||||
const slot = semanticPropertySlot(
|
||||
context,
|
||||
binding.bindingId,
|
||||
property.name,
|
||||
);
|
||||
addSemanticGraphRelation(context.state, {
|
||||
source: target,
|
||||
target: slot,
|
||||
relation: "writes-property",
|
||||
resolution: value.unknownProperties ? "candidate" : "resolved",
|
||||
});
|
||||
projectValue({
|
||||
context,
|
||||
binding,
|
||||
value: property.value,
|
||||
target: slot,
|
||||
role: `property:${property.name}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addLiteralNode = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
binding: JavaScriptSemanticBinding,
|
||||
value: string | number | boolean | null,
|
||||
role: string,
|
||||
) =>
|
||||
addSemanticGraphNode(
|
||||
context.state,
|
||||
constructSemanticGraphNode(
|
||||
context.file,
|
||||
{
|
||||
kind: "literal",
|
||||
roleKey: `literal:${binding.bindingId}:${role}:${JSON.stringify(value)}`,
|
||||
location: binding.definitions[0]?.location ?? null,
|
||||
label: JSON.stringify(value),
|
||||
functionNodeId:
|
||||
context.bindingNodes.get(binding.bindingId)?.function_node_id ?? null,
|
||||
properties: { value },
|
||||
},
|
||||
context.state,
|
||||
),
|
||||
);
|
||||
|
||||
/** Create one canonical property slot for a binding/property identity. */
|
||||
export const semanticPropertySlot = (
|
||||
context: SemanticFlowProjectionContext,
|
||||
objectBindingId: string,
|
||||
name: string,
|
||||
) =>
|
||||
addSemanticGraphNode(
|
||||
context.state,
|
||||
constructSemanticGraphNode(
|
||||
context.file,
|
||||
{
|
||||
kind: "property-slot",
|
||||
roleKey: `property:${objectBindingId}:${name}`,
|
||||
location: null,
|
||||
label: name,
|
||||
functionNodeId:
|
||||
context.bindingNodes.get(objectBindingId)?.function_node_id ?? null,
|
||||
properties: {
|
||||
name,
|
||||
object_binding_id: objectBindingId,
|
||||
} satisfies Readonly<Record<string, JsonValue>>,
|
||||
},
|
||||
context.state,
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ProcessScenario } from "../domain/processCapture.js";
|
||||
import type { LoopbackReplay } from "./LoopbackReplay.js";
|
||||
import type { CommandShimReplay } from "./CommandShimReplay.js";
|
||||
|
||||
interface ProcessCaptureEnvironmentOptions {
|
||||
readonly scenario: ProcessScenario;
|
||||
readonly home: string;
|
||||
readonly replay: LoopbackReplay;
|
||||
readonly shimReplay: CommandShimReplay;
|
||||
readonly runId: string;
|
||||
}
|
||||
|
||||
/** Build the explicitly admitted environment for a captured process. */
|
||||
export const makeProcessCaptureEnvironment = (
|
||||
options: ProcessCaptureEnvironmentOptions,
|
||||
): Record<string, string> => {
|
||||
const { scenario, home, replay, shimReplay, runId } = options;
|
||||
const environment: Record<string, string> = {
|
||||
...scenario.environment,
|
||||
HOME: home,
|
||||
TERM: "xterm-256color",
|
||||
REA_PROCESS_RUN_ID: runId,
|
||||
};
|
||||
for (const name of scenario.inherit_environment) {
|
||||
const value = process.env[name];
|
||||
if (value !== undefined) environment[name] = value;
|
||||
}
|
||||
environment.REA_REPLAY_HTTP_URL = replay.httpUrl;
|
||||
environment.REA_REPLAY_WEBSOCKET_URL = replay.websocketUrl;
|
||||
environment.REA_SHIM_LEDGER_URL = shimReplay.url;
|
||||
// PATH is evidence-producing input. Inheriting the host PATH here would let
|
||||
// the authority and reconstruction probe different tools without recording
|
||||
// that difference. Scenarios must opt in through environment or
|
||||
// inherit_environment; deterministic shims always take precedence.
|
||||
environment.PATH = [shimReplay.binPath, environment.PATH ?? ""]
|
||||
.filter((part) => part.length > 0)
|
||||
.join(":");
|
||||
return environment;
|
||||
};
|
||||
@@ -71,6 +71,7 @@ interface CaptureResultOptions {
|
||||
};
|
||||
readonly samples: readonly ProcessSample[];
|
||||
readonly replay: LoopbackReplay;
|
||||
readonly reactiveRun: ProcessCapture["reactive_run"];
|
||||
readonly before: SnapshotResult;
|
||||
readonly after: SnapshotResult;
|
||||
readonly truncated: boolean;
|
||||
@@ -116,6 +117,7 @@ export const buildCaptureResult = (
|
||||
options.scenario,
|
||||
),
|
||||
replay_transitions: options.replay.transitions,
|
||||
reactive_run: options.reactiveRun,
|
||||
event_journal: options.eventJournal,
|
||||
files_before: options.before.files,
|
||||
files_after: options.after.files,
|
||||
|
||||
@@ -106,6 +106,12 @@ export class ProcessCheckpoints {
|
||||
this.#scheduleCapture(name, this.#signal);
|
||||
}
|
||||
|
||||
/** Read one committed checkpoint for synchronous journal projection. */
|
||||
at(index: number): FilesystemCheckpoint | undefined {
|
||||
const checkpoint = this.#captures[index];
|
||||
return checkpoint === undefined ? undefined : structuredClone(checkpoint);
|
||||
}
|
||||
|
||||
#scheduleCapture(name: string, signal: AbortSignal | undefined): void {
|
||||
if (this.#captured.has(name)) return;
|
||||
this.#captured.add(name);
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
ProcessCaptureError,
|
||||
processCaptureCancelled,
|
||||
} from "./ProcessCaptureError.js";
|
||||
|
||||
export { ProcessCaptureError } from "./ProcessCaptureError.js";
|
||||
import { startLoopbackReplay, type LoopbackReplay } from "./LoopbackReplay.js";
|
||||
import { startProcessSampler } from "./ProcessSampling.js";
|
||||
@@ -26,7 +25,10 @@ import {
|
||||
startCommandShimReplay,
|
||||
type CommandShimReplay,
|
||||
} from "./CommandShimReplay.js";
|
||||
import { normalizeProcessText } from "./ProcessNormalization.js";
|
||||
import {
|
||||
normalizeProcessShimEvent,
|
||||
normalizeProcessText,
|
||||
} from "./ProcessNormalization.js";
|
||||
import { selectCapturedProcessGroupIds } from "../process/ProcessOwnership.js";
|
||||
import {
|
||||
awaitTerminalExit,
|
||||
@@ -42,45 +44,17 @@ import { assertNotCancelled } from "./ProcessCaptureAuthority.js";
|
||||
import {
|
||||
createProcessCaptureJournal,
|
||||
scheduleScenarioInteractions,
|
||||
type ProcessCaptureJournal,
|
||||
} from "./ProcessCaptureJournal.js";
|
||||
|
||||
import {
|
||||
assertSupportedReactiveScenario,
|
||||
projectProcessReactiveRun,
|
||||
startProcessReactiveHarness,
|
||||
type ProcessReactiveHarness,
|
||||
} from "./ProcessReactiveHarness.js";
|
||||
import { makeProcessCaptureEnvironment } from "./ProcessCaptureEnvironment.js";
|
||||
export { probeProcessCaptureCapability } from "./ProcessCaptureCapability.js";
|
||||
|
||||
interface EnvironmentOptions {
|
||||
readonly scenario: ProcessScenario;
|
||||
readonly home: string;
|
||||
readonly replay: LoopbackReplay;
|
||||
readonly shimReplay: CommandShimReplay;
|
||||
readonly runId: string;
|
||||
}
|
||||
|
||||
const makeEnvironment = (
|
||||
options: EnvironmentOptions,
|
||||
): Record<string, string> => {
|
||||
const { scenario, home, replay, shimReplay, runId } = options;
|
||||
const environment: Record<string, string> = {
|
||||
...scenario.environment,
|
||||
HOME: home,
|
||||
TERM: "xterm-256color",
|
||||
REA_PROCESS_RUN_ID: runId,
|
||||
};
|
||||
for (const name of scenario.inherit_environment) {
|
||||
const value = process.env[name];
|
||||
if (value !== undefined) environment[name] = value;
|
||||
}
|
||||
environment.REA_REPLAY_HTTP_URL = replay.httpUrl;
|
||||
environment.REA_REPLAY_WEBSOCKET_URL = replay.websocketUrl;
|
||||
environment.REA_SHIM_LEDGER_URL = shimReplay.url;
|
||||
// PATH is evidence-producing input. Inheriting the host PATH here would let
|
||||
// the authority and reconstruction probe different tools without recording
|
||||
// that difference. Scenarios must opt in through environment or
|
||||
// inherit_environment; deterministic shims always take precedence.
|
||||
environment.PATH = [shimReplay.binPath, environment.PATH ?? ""]
|
||||
.filter((part) => part.length > 0)
|
||||
.join(":");
|
||||
return environment;
|
||||
};
|
||||
|
||||
interface StartedCaptureRuntime {
|
||||
readonly replay: LoopbackReplay;
|
||||
readonly renderer: TerminalRenderer;
|
||||
@@ -92,6 +66,7 @@ interface StartedCaptureRuntime {
|
||||
readonly lastOutput: () => number;
|
||||
readonly framesTruncated: () => boolean;
|
||||
readonly stopSampler: () => Promise<{ readonly partial: boolean }>;
|
||||
readonly reactive: ProcessReactiveHarness | undefined;
|
||||
}
|
||||
|
||||
const cleanupFailedStartup = async (options: {
|
||||
@@ -136,7 +111,7 @@ const createTerminalRenderer = (
|
||||
recordEvent,
|
||||
});
|
||||
|
||||
const startCaptureRuntime = async (options: {
|
||||
interface StartCaptureRuntimeOptions {
|
||||
readonly scenario: ProcessScenario;
|
||||
readonly home: string;
|
||||
readonly temporaryRoot: string;
|
||||
@@ -148,14 +123,29 @@ const startCaptureRuntime = async (options: {
|
||||
readonly timers: Set<NodeJS.Timeout>;
|
||||
readonly dispatchedEventIndexes: Set<number>;
|
||||
readonly recordEvent: RecordProcessCaptureEvent;
|
||||
readonly journal: ProcessCaptureJournal;
|
||||
readonly signal?: AbortSignal;
|
||||
}): Promise<StartedCaptureRuntime> => {
|
||||
}
|
||||
|
||||
const reactiveCapture = (
|
||||
options: StartCaptureRuntimeOptions,
|
||||
protocolEvents: () => LoopbackReplay["events"],
|
||||
): Parameters<typeof startProcessReactiveHarness>[0]["capture"] => ({
|
||||
...options,
|
||||
processSamples: options.samples,
|
||||
protocolEvents,
|
||||
});
|
||||
|
||||
const startCaptureRuntime = async (
|
||||
options: StartCaptureRuntimeOptions,
|
||||
): Promise<StartedCaptureRuntime> => {
|
||||
const { scenario } = options;
|
||||
let replay: LoopbackReplay | undefined;
|
||||
let renderer: TerminalRenderer | undefined;
|
||||
let checkpoints: ProcessCheckpoints | undefined;
|
||||
let shimReplay: CommandShimReplay | undefined;
|
||||
let terminal: IPty | undefined;
|
||||
let reactive: ProcessReactiveHarness | undefined;
|
||||
try {
|
||||
const { spawn } = await import("@lydell/node-pty");
|
||||
replay = await startLoopbackReplay(scenario, options.recordEvent);
|
||||
@@ -180,7 +170,7 @@ const startCaptureRuntime = async (options: {
|
||||
);
|
||||
terminal = spawn(scenario.executable, [...scenario.arguments], {
|
||||
cwd: scenario.working_directory,
|
||||
env: makeEnvironment({ ...options, replay, shimReplay }),
|
||||
env: makeProcessCaptureEnvironment({ ...options, replay, shimReplay }),
|
||||
cols: scenario.terminal.columns,
|
||||
rows: scenario.terminal.rows,
|
||||
name: "xterm-256color",
|
||||
@@ -193,6 +183,15 @@ const startCaptureRuntime = async (options: {
|
||||
renderer,
|
||||
checkpoints,
|
||||
});
|
||||
reactive = startProcessReactiveHarness({
|
||||
capture: reactiveCapture(options, () => replay?.events ?? []),
|
||||
scenario,
|
||||
terminal: () => terminal,
|
||||
renderer,
|
||||
checkpoints,
|
||||
shimReplay,
|
||||
started,
|
||||
});
|
||||
scheduleScenarioInteractions({
|
||||
...options,
|
||||
getTerminal: () => terminal,
|
||||
@@ -218,8 +217,11 @@ const startCaptureRuntime = async (options: {
|
||||
lastOutput: () => lastOutput,
|
||||
framesTruncated,
|
||||
stopSampler,
|
||||
reactive,
|
||||
};
|
||||
} catch (cause: unknown) {
|
||||
reactive?.unsubscribe();
|
||||
await reactive?.coordinator.close();
|
||||
return cleanupFailedStartup({
|
||||
cause,
|
||||
timers: options.timers,
|
||||
@@ -239,23 +241,14 @@ const normalizedShimEvents = (
|
||||
scenario: ProcessScenario,
|
||||
temporaryRoot: string,
|
||||
): ProcessCapture["shim_events"] =>
|
||||
runtime.shimReplay.events.map((event) => ({
|
||||
...event,
|
||||
arguments: event.arguments.map((argument) =>
|
||||
normalizeProcessText(
|
||||
argument,
|
||||
scenario,
|
||||
temporaryRoot,
|
||||
runtime.terminal.pid,
|
||||
),
|
||||
),
|
||||
working_directory: normalizeProcessText(
|
||||
event.working_directory,
|
||||
runtime.shimReplay.events.map((event) =>
|
||||
normalizeProcessShimEvent(
|
||||
event,
|
||||
scenario,
|
||||
temporaryRoot,
|
||||
runtime.terminal.pid,
|
||||
),
|
||||
}));
|
||||
);
|
||||
|
||||
const finishProcessRun = async (options: {
|
||||
readonly runtime: StartedCaptureRuntime | undefined;
|
||||
@@ -267,6 +260,7 @@ const finishProcessRun = async (options: {
|
||||
readonly capture: ProcessCapture | undefined;
|
||||
readonly executionFailure: unknown;
|
||||
}): Promise<ProcessCapture> => {
|
||||
options.runtime?.reactive?.unsubscribe();
|
||||
await options.stopSampler();
|
||||
const cleanupFailure = await releaseProcessResources({
|
||||
timers: options.timers,
|
||||
@@ -285,10 +279,16 @@ const finishProcessRun = async (options: {
|
||||
options.samples,
|
||||
),
|
||||
});
|
||||
if (cleanupFailure !== undefined)
|
||||
await options.runtime?.reactive?.coordinator.submit({
|
||||
kind: "cleanup_failed",
|
||||
});
|
||||
await options.runtime?.reactive?.coordinator.close();
|
||||
let { capture, executionFailure } = options;
|
||||
if (capture !== undefined) {
|
||||
capture = {
|
||||
...capture,
|
||||
reactive_run: projectProcessReactiveRun(options.runtime?.reactive),
|
||||
settlement: {
|
||||
...capture.settlement,
|
||||
cleanup_outcome:
|
||||
@@ -326,7 +326,17 @@ const completeCapture = async (options: {
|
||||
const { runtime, scenario } = options;
|
||||
runtime.checkpoints.trigger("root_exit");
|
||||
const { reason } = options.exit;
|
||||
if (reason === "cancelled") throw processCaptureCancelled();
|
||||
if (reason === "cancelled") {
|
||||
if (runtime.reactive !== undefined) {
|
||||
await runtime.reactive.coordinator.submit({ kind: "cancelled" });
|
||||
runtime.reactive.unsubscribe();
|
||||
}
|
||||
throw processCaptureCancelled();
|
||||
}
|
||||
if (runtime.reactive !== undefined) {
|
||||
await runtime.reactive.coordinator.submit({ kind: "target_lost" });
|
||||
runtime.reactive.unsubscribe();
|
||||
}
|
||||
const settlement = await observeSettlement(
|
||||
options.runId,
|
||||
selectCapturedProcessGroupIds(runtime.terminal.pid, options.samples),
|
||||
@@ -359,6 +369,7 @@ const completeCapture = async (options: {
|
||||
exit: { ...options.exit, reason },
|
||||
samples: options.samples,
|
||||
replay: runtime.replay,
|
||||
reactiveRun: projectProcessReactiveRun(runtime.reactive),
|
||||
before: options.before,
|
||||
after,
|
||||
truncated,
|
||||
@@ -381,6 +392,7 @@ const runProcessScenario = async (
|
||||
policy: ProcessExecutionPolicy,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ProcessCapture> => {
|
||||
assertSupportedReactiveScenario(scenario);
|
||||
const { temporaryRoot, runId, home, before } = await prepareProcessCapture(
|
||||
scenario,
|
||||
policy,
|
||||
@@ -388,7 +400,8 @@ const runProcessScenario = async (
|
||||
);
|
||||
const frames: TerminalFrame[] = [];
|
||||
const samples: ProcessSample[] = [];
|
||||
const { entries: eventJournal, recordEvent } = createProcessCaptureJournal();
|
||||
const journal = createProcessCaptureJournal();
|
||||
const { entries: eventJournal, recordEvent } = journal;
|
||||
let runtime: StartedCaptureRuntime | undefined;
|
||||
const timers = new Set<NodeJS.Timeout>();
|
||||
let capture: ProcessCapture | undefined;
|
||||
@@ -410,6 +423,7 @@ const runProcessScenario = async (
|
||||
timers,
|
||||
dispatchedEventIndexes,
|
||||
recordEvent,
|
||||
journal,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
});
|
||||
stopSampler = runtime.stopSampler;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
ProcessCapture,
|
||||
ProcessSample,
|
||||
ProcessScenario,
|
||||
ShimEvent,
|
||||
} from "../domain/processCapture.js";
|
||||
|
||||
/** Bucket one elapsed process-capture timestamp under scenario normalization. */
|
||||
@@ -91,6 +92,25 @@ export const normalizeProcessSamples = (
|
||||
}));
|
||||
};
|
||||
|
||||
/** Normalize one shim observation identically for live matching and capture. */
|
||||
export const normalizeProcessShimEvent = (
|
||||
event: ShimEvent,
|
||||
scenario: ProcessScenario,
|
||||
temporaryRoot: string,
|
||||
rootPid: number,
|
||||
): ShimEvent => ({
|
||||
...event,
|
||||
arguments: event.arguments.map((argument) =>
|
||||
normalizeProcessText(argument, scenario, temporaryRoot, rootPid),
|
||||
),
|
||||
working_directory: normalizeProcessText(
|
||||
event.working_directory,
|
||||
scenario,
|
||||
temporaryRoot,
|
||||
rootPid,
|
||||
),
|
||||
});
|
||||
|
||||
const normalizePidTokens = (
|
||||
value: string,
|
||||
identifiers: readonly number[],
|
||||
|
||||
@@ -51,7 +51,7 @@ export class ProcessReactiveCoordinator {
|
||||
readonly #now: () => number;
|
||||
readonly #scenarioDeadlineAt: number;
|
||||
readonly #onDecision:
|
||||
| ((decision: ProcessReactiveDecision) => void)
|
||||
| ((decision: ProcessReactiveDecision, input: ProcessReactiveInput) => void)
|
||||
| undefined;
|
||||
#snapshot: ProcessReactiveSnapshot;
|
||||
readonly #pendingInputs: ProcessReactiveInput[] = [];
|
||||
@@ -70,7 +70,10 @@ export class ProcessReactiveCoordinator {
|
||||
readonly executor: ProcessReactiveExecutor;
|
||||
readonly timerHost?: ProcessReactiveTimerHost;
|
||||
readonly now?: () => number;
|
||||
readonly onDecision?: (decision: ProcessReactiveDecision) => void;
|
||||
readonly onDecision?: (
|
||||
decision: ProcessReactiveDecision,
|
||||
input: ProcessReactiveInput,
|
||||
) => void;
|
||||
}) {
|
||||
this.#scenario = options.scenario;
|
||||
this.#executor = options.executor;
|
||||
@@ -206,7 +209,7 @@ export class ProcessReactiveCoordinator {
|
||||
);
|
||||
}
|
||||
this.#snapshot = decision.snapshot;
|
||||
this.#onDecision?.(decision);
|
||||
this.#onDecision?.(decision, input);
|
||||
if (this.#snapshot.status === "finished") this.#clearTimers();
|
||||
else if (
|
||||
this.#accepting &&
|
||||
@@ -216,18 +219,35 @@ export class ProcessReactiveCoordinator {
|
||||
)
|
||||
this.#scheduleStateDeadline();
|
||||
for (const result of effectResults)
|
||||
if (result.status === "succeeded")
|
||||
if (
|
||||
result.status === "succeeded" &&
|
||||
!this.#hasObservation(result.observation.event_id)
|
||||
)
|
||||
this.#enqueueOwned({
|
||||
kind: "observation",
|
||||
observation: result.observation,
|
||||
});
|
||||
}
|
||||
|
||||
#hasObservation(eventId: string): boolean {
|
||||
return (
|
||||
this.#snapshot.observations.some(
|
||||
(observation) => observation.event_id === eventId,
|
||||
) ||
|
||||
this.#pendingInputs.some(
|
||||
(input) =>
|
||||
input.kind === "observation" &&
|
||||
input.observation.event_id === eventId,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#interruptsEffects(input: ProcessReactiveInput): boolean {
|
||||
if (
|
||||
input.kind === "cancelled" ||
|
||||
input.kind === "cleanup_failed" ||
|
||||
input.kind === "scenario_deadline"
|
||||
input.kind === "scenario_deadline" ||
|
||||
input.kind === "target_lost"
|
||||
)
|
||||
return true;
|
||||
return (
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { ProcessCaptureJournal } from "./ProcessCaptureJournal.js";
|
||||
export interface ProcessReactiveTerminal {
|
||||
write(data: string): void;
|
||||
resize(columns: number, rows: number): void;
|
||||
closeInput(): void;
|
||||
kill(signal: "SIGINT" | "SIGTERM" | "SIGKILL"): void;
|
||||
}
|
||||
|
||||
@@ -56,6 +57,7 @@ const interactionData = (
|
||||
: action.data;
|
||||
if (action.type === "resize")
|
||||
return `${String(action.columns)}x${String(action.rows)}`;
|
||||
if (action.type === "close_stdin") return "";
|
||||
return action.signal;
|
||||
};
|
||||
|
||||
@@ -64,6 +66,7 @@ const interactionType = (
|
||||
): InteractionEvent["type"] => {
|
||||
if (action.type === "send_input") return "input";
|
||||
if (action.type === "resize") return "resize";
|
||||
if (action.type === "close_stdin") return "stdin_close";
|
||||
return "signal";
|
||||
};
|
||||
|
||||
@@ -77,7 +80,8 @@ const performTerminalAction = (
|
||||
else if (action.type === "resize") {
|
||||
terminal.resize(action.columns, action.rows);
|
||||
renderer.resize(action.columns, action.rows, atMs);
|
||||
} else terminal.kill(action.signal);
|
||||
} else if (action.type === "close_stdin") terminal.closeInput();
|
||||
else terminal.kill(action.signal);
|
||||
};
|
||||
|
||||
const recordInteraction = (
|
||||
@@ -184,9 +188,15 @@ const triggerSources = (
|
||||
): readonly string[] => {
|
||||
if (trigger.kind === "terminal_text") return [];
|
||||
if (trigger.kind === "event")
|
||||
return ["terminal_raw", "interaction", "filesystem"].includes(
|
||||
trigger.source,
|
||||
)
|
||||
return [
|
||||
"terminal_raw",
|
||||
"interaction",
|
||||
"process",
|
||||
"filesystem",
|
||||
"http",
|
||||
"websocket",
|
||||
"shim",
|
||||
].includes(trigger.source)
|
||||
? []
|
||||
: [trigger.source];
|
||||
return trigger.kind === "repeat"
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { IPty } from "@lydell/node-pty";
|
||||
|
||||
import type {
|
||||
InteractionEvent,
|
||||
ProcessCapture,
|
||||
ProcessReactiveControlRecord,
|
||||
ProcessSample,
|
||||
ProcessScenario,
|
||||
ProtocolEvent,
|
||||
TerminalFrame,
|
||||
} from "../domain/processCapture.js";
|
||||
import type { ProcessReactiveInput } from "../domain/processReactiveRuntime.js";
|
||||
import { ProcessCaptureError } from "./ProcessCaptureError.js";
|
||||
import type { ProcessCaptureJournal } from "./ProcessCaptureJournal.js";
|
||||
import type { ProcessCheckpoints } from "./ProcessCheckpoints.js";
|
||||
import type { CommandShimReplay } from "./CommandShimReplay.js";
|
||||
import { ProcessReactiveCoordinator } from "./ProcessReactiveCoordinator.js";
|
||||
import {
|
||||
executeProcessReactiveEffects,
|
||||
type ProcessReactiveTerminal,
|
||||
unsupportedProcessReactiveFeatures,
|
||||
} from "./ProcessReactiveEffects.js";
|
||||
import { subscribeProcessReactiveObservations } from "./ProcessReactiveObservations.js";
|
||||
import {
|
||||
normalizeProcessSamples,
|
||||
normalizeProcessShimEvent,
|
||||
redactProtocolEvents,
|
||||
} from "./ProcessNormalization.js";
|
||||
import type { TerminalRenderer } from "./TerminalRenderer.js";
|
||||
|
||||
/** Coordinator and subscription owned by one live process capture. */
|
||||
export interface ProcessReactiveHarness {
|
||||
readonly coordinator: ProcessReactiveCoordinator;
|
||||
readonly controls: readonly ProcessReactiveControlRecord[];
|
||||
readonly unsubscribe: () => void;
|
||||
}
|
||||
|
||||
const reactiveTerminalFor = (
|
||||
terminal: IPty | undefined,
|
||||
): ProcessReactiveTerminal | undefined => {
|
||||
if (terminal === undefined) return undefined;
|
||||
return {
|
||||
write: (data) => terminal.write(data),
|
||||
resize: (columns, rows) => terminal.resize(columns, rows),
|
||||
closeInput: () => {
|
||||
const end: unknown = Reflect.get(terminal, "end");
|
||||
if (typeof end !== "function")
|
||||
throw new ProcessCaptureError(
|
||||
"PTY backend does not expose an stdin-close operation",
|
||||
);
|
||||
Reflect.apply(end, terminal, []);
|
||||
},
|
||||
kill: (signal) => terminal.kill(signal),
|
||||
};
|
||||
};
|
||||
|
||||
const controlKind = (
|
||||
input: ProcessReactiveInput,
|
||||
): ProcessReactiveControlRecord["kind"] | undefined =>
|
||||
input.kind === "observation" ? undefined : input.kind;
|
||||
|
||||
/** Reject reactive declarations the current process adapter cannot execute. */
|
||||
export const assertSupportedReactiveScenario = (
|
||||
scenario: ProcessScenario,
|
||||
): void => {
|
||||
if (scenario.reactive === null) return;
|
||||
const unsupported = unsupportedProcessReactiveFeatures(scenario.reactive);
|
||||
if (unsupported.length === 0) return;
|
||||
throw new ProcessCaptureError(
|
||||
`reactive scenario requests unsupported features: ${unsupported.join(", ")}`,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Attach the serialized reactive runtime once the target PID is available.
|
||||
*
|
||||
* Subscription replays journal entries admitted during launcher startup before
|
||||
* observing new records, so PID-dependent normalization cannot drop them.
|
||||
*/
|
||||
export const startProcessReactiveHarness = (options: {
|
||||
readonly scenario: ProcessScenario;
|
||||
readonly terminal: () => IPty | undefined;
|
||||
readonly renderer: TerminalRenderer;
|
||||
readonly checkpoints: ProcessCheckpoints;
|
||||
readonly shimReplay: CommandShimReplay;
|
||||
readonly started: number;
|
||||
readonly capture: {
|
||||
readonly interactions: InteractionEvent[];
|
||||
readonly journal: ProcessCaptureJournal;
|
||||
readonly frames: readonly TerminalFrame[];
|
||||
readonly processSamples: readonly ProcessSample[];
|
||||
readonly protocolEvents: () => readonly ProtocolEvent[];
|
||||
readonly temporaryRoot: string;
|
||||
};
|
||||
}): ProcessReactiveHarness | undefined => {
|
||||
if (options.scenario.reactive === null) return undefined;
|
||||
const controls: ProcessReactiveControlRecord[] = [];
|
||||
const coordinator = new ProcessReactiveCoordinator({
|
||||
scenario: options.scenario.reactive,
|
||||
executor: {
|
||||
execute: (actions, signal) =>
|
||||
executeProcessReactiveEffects(
|
||||
{
|
||||
terminal: () => reactiveTerminalFor(options.terminal()),
|
||||
renderer: options.renderer,
|
||||
checkpoints: options.checkpoints,
|
||||
interactions: options.capture.interactions,
|
||||
journal: options.capture.journal,
|
||||
startedAtMs: options.started,
|
||||
now: Date.now,
|
||||
},
|
||||
actions,
|
||||
signal,
|
||||
),
|
||||
},
|
||||
onDecision: (decision, input) => {
|
||||
const kind = controlKind(input);
|
||||
if (kind === undefined || decision.kind !== "finished") return;
|
||||
controls.push({
|
||||
sequence: controls.length,
|
||||
kind,
|
||||
after_capture_order:
|
||||
decision.snapshot.observations.at(-1)?.capture_order ?? -1,
|
||||
});
|
||||
},
|
||||
});
|
||||
return {
|
||||
coordinator,
|
||||
controls,
|
||||
unsubscribe: subscribeProcessReactiveObservations({
|
||||
journal: options.capture.journal,
|
||||
frames: options.capture.frames,
|
||||
interactions: options.capture.interactions,
|
||||
checkpointAt: (index) => options.checkpoints.at(index),
|
||||
processSampleAt: (index) => {
|
||||
const rootPid = options.terminal()?.pid;
|
||||
return rootPid === undefined
|
||||
? undefined
|
||||
: normalizeProcessSamples(
|
||||
options.capture.processSamples,
|
||||
options.scenario,
|
||||
rootPid,
|
||||
)[index];
|
||||
},
|
||||
protocolEventAt: (index) =>
|
||||
redactProtocolEvents(
|
||||
options.capture.protocolEvents().slice(index, index + 1),
|
||||
options.scenario,
|
||||
)[0],
|
||||
shimEventAt: (index) => {
|
||||
const event = options.shimReplay.events[index];
|
||||
const rootPid = options.terminal()?.pid;
|
||||
return event === undefined || rootPid === undefined
|
||||
? undefined
|
||||
: normalizeProcessShimEvent(
|
||||
event,
|
||||
options.scenario,
|
||||
options.capture.temporaryRoot,
|
||||
rootPid,
|
||||
);
|
||||
},
|
||||
sink: coordinator,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
/** Project internal reducer state into the stable Process Capture shape. */
|
||||
export const projectProcessReactiveRun = (
|
||||
harness: ProcessReactiveHarness | undefined,
|
||||
): ProcessCapture["reactive_run"] =>
|
||||
harness === undefined
|
||||
? null
|
||||
: {
|
||||
status: harness.coordinator.snapshot.status,
|
||||
outcome: harness.coordinator.snapshot.outcome,
|
||||
active_state: harness.coordinator.snapshot.active_state,
|
||||
transitions: harness.coordinator.snapshot.transitions,
|
||||
controls: harness.controls,
|
||||
};
|
||||
@@ -1,8 +1,17 @@
|
||||
import type {
|
||||
InteractionEvent,
|
||||
FilesystemCheckpoint,
|
||||
ProcessCaptureEventJournalEntry,
|
||||
ProcessSample,
|
||||
ProtocolEvent,
|
||||
ShimEvent,
|
||||
TerminalFrame,
|
||||
} from "../domain/processCapture.js";
|
||||
import { createProcessObservation } from "../domain/processObservation.js";
|
||||
import {
|
||||
createProcessObservation,
|
||||
processObservationSubject,
|
||||
type ProcessObservation,
|
||||
} from "../domain/processObservation.js";
|
||||
import type { ProcessReactiveInput } from "../domain/processReactiveRuntime.js";
|
||||
import type { ProcessCaptureJournal } from "./ProcessCaptureJournal.js";
|
||||
|
||||
@@ -11,25 +20,145 @@ export interface ProcessReactiveObservationSink {
|
||||
enqueue(input: ProcessReactiveInput): void;
|
||||
}
|
||||
|
||||
/** Subscribe a reactive coordinator to normalized raw PTY journal records. */
|
||||
export const subscribeProcessReactiveTerminalObservations = (options: {
|
||||
interface ProcessReactiveObservationOptions {
|
||||
readonly journal: ProcessCaptureJournal;
|
||||
readonly frames: readonly TerminalFrame[];
|
||||
readonly interactions: readonly InteractionEvent[];
|
||||
readonly checkpointAt: (index: number) => FilesystemCheckpoint | undefined;
|
||||
readonly processSampleAt: (index: number) => ProcessSample | undefined;
|
||||
readonly protocolEventAt: (index: number) => ProtocolEvent | undefined;
|
||||
readonly shimEventAt: (index: number) => ShimEvent | undefined;
|
||||
readonly sink: ProcessReactiveObservationSink;
|
||||
}): (() => void) =>
|
||||
options.journal.subscribe((entry: ProcessCaptureEventJournalEntry) => {
|
||||
if (entry.collection !== "frames") return;
|
||||
const frame = options.frames[entry.index];
|
||||
if (frame === undefined) return;
|
||||
options.sink.enqueue({
|
||||
kind: "observation",
|
||||
observation: createProcessObservation({
|
||||
}
|
||||
|
||||
const terminalObservation = (
|
||||
options: ProcessReactiveObservationOptions,
|
||||
entry: ProcessCaptureEventJournalEntry,
|
||||
): ProcessObservation | undefined => {
|
||||
const value = options.frames[entry.index];
|
||||
return value === undefined
|
||||
? undefined
|
||||
: createProcessObservation({
|
||||
source: "terminal_raw",
|
||||
source_sequence: frame.sequence,
|
||||
captured_at_ms: frame.at_ms,
|
||||
source_sequence: value.sequence,
|
||||
captured_at_ms: value.at_ms,
|
||||
subject_id: null,
|
||||
location: entry,
|
||||
payload: frame,
|
||||
}),
|
||||
});
|
||||
payload: value,
|
||||
});
|
||||
};
|
||||
|
||||
const interactionObservation = (
|
||||
options: ProcessReactiveObservationOptions,
|
||||
entry: ProcessCaptureEventJournalEntry,
|
||||
): ProcessObservation | undefined => {
|
||||
const value = options.interactions[entry.index];
|
||||
return value === undefined
|
||||
? undefined
|
||||
: createProcessObservation({
|
||||
source: "interaction",
|
||||
source_sequence: value.sequence,
|
||||
captured_at_ms: value.dispatched_at_ms,
|
||||
subject_id: null,
|
||||
location: entry,
|
||||
payload: value,
|
||||
});
|
||||
};
|
||||
|
||||
const filesystemObservation = (
|
||||
options: ProcessReactiveObservationOptions,
|
||||
entry: ProcessCaptureEventJournalEntry,
|
||||
): ProcessObservation | undefined => {
|
||||
const value = options.checkpointAt(entry.index);
|
||||
return value === undefined
|
||||
? undefined
|
||||
: createProcessObservation({
|
||||
source: "filesystem",
|
||||
source_sequence: entry.index,
|
||||
captured_at_ms: value.at_ms,
|
||||
subject_id: processObservationSubject("filesystem", value),
|
||||
location: entry,
|
||||
payload: value,
|
||||
});
|
||||
};
|
||||
|
||||
const processObservation = (
|
||||
options: ProcessReactiveObservationOptions,
|
||||
entry: ProcessCaptureEventJournalEntry,
|
||||
): ProcessObservation | undefined => {
|
||||
const value = options.processSampleAt(entry.index);
|
||||
return value === undefined
|
||||
? undefined
|
||||
: createProcessObservation({
|
||||
source: "process",
|
||||
source_sequence: entry.index,
|
||||
captured_at_ms: value.at_ms,
|
||||
subject_id: processObservationSubject("process", value),
|
||||
location: entry,
|
||||
payload: value,
|
||||
});
|
||||
};
|
||||
|
||||
const protocolObservation = (
|
||||
options: ProcessReactiveObservationOptions,
|
||||
entry: ProcessCaptureEventJournalEntry,
|
||||
): ProcessObservation | undefined => {
|
||||
const value = options.protocolEventAt(entry.index);
|
||||
return value === undefined
|
||||
? undefined
|
||||
: createProcessObservation({
|
||||
source: value.protocol,
|
||||
source_sequence: value.sequence,
|
||||
captured_at_ms: value.at_ms,
|
||||
subject_id: processObservationSubject(value.protocol, value),
|
||||
location: entry,
|
||||
payload: value,
|
||||
});
|
||||
};
|
||||
|
||||
const shimObservation = (
|
||||
options: ProcessReactiveObservationOptions,
|
||||
entry: ProcessCaptureEventJournalEntry,
|
||||
): ProcessObservation | undefined => {
|
||||
const normalized = options.shimEventAt(entry.index);
|
||||
if (normalized === undefined) return undefined;
|
||||
return createProcessObservation({
|
||||
source: "shim",
|
||||
source_sequence: normalized.sequence,
|
||||
captured_at_ms: normalized.at_ms,
|
||||
subject_id: processObservationSubject("shim", normalized),
|
||||
location: entry,
|
||||
payload: normalized,
|
||||
});
|
||||
};
|
||||
|
||||
const observationFor = (
|
||||
options: ProcessReactiveObservationOptions,
|
||||
entry: ProcessCaptureEventJournalEntry,
|
||||
): ProcessObservation | undefined => {
|
||||
if (entry.collection === "frames") return terminalObservation(options, entry);
|
||||
if (entry.collection === "interaction_events")
|
||||
return interactionObservation(options, entry);
|
||||
if (entry.collection === "filesystem_checkpoints")
|
||||
return filesystemObservation(options, entry);
|
||||
if (entry.collection === "process_samples")
|
||||
return processObservation(options, entry);
|
||||
if (entry.collection === "protocol_events")
|
||||
return protocolObservation(options, entry);
|
||||
if (entry.collection === "shim_events")
|
||||
return shimObservation(options, entry);
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Subscribe the reactive coordinator to admitted normalized journal records. */
|
||||
export const subscribeProcessReactiveObservations = (
|
||||
options: ProcessReactiveObservationOptions,
|
||||
): (() => void) => {
|
||||
const enqueue = (entry: ProcessCaptureEventJournalEntry): void => {
|
||||
const observation = observationFor(options, entry);
|
||||
if (observation !== undefined)
|
||||
options.sink.enqueue({ kind: "observation", observation });
|
||||
};
|
||||
for (const entry of options.journal.entries) enqueue(entry);
|
||||
return options.journal.subscribe(enqueue);
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ const grantSchema = z.object({
|
||||
capability: z.enum([
|
||||
"process_capture",
|
||||
"browser_observe",
|
||||
"browser_automate",
|
||||
"electron_observe",
|
||||
"evidence_read",
|
||||
"evidence_write",
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import type { ReconstructionObligation } from "../domain/reconstructionObligationLedgerSchemas.js";
|
||||
|
||||
type ProofAuthority = ReconstructionObligation["required_fixture_authority"];
|
||||
type OriginalAuthority =
|
||||
ReconstructionObligation["required_original_authority"];
|
||||
interface ExpectedProof {
|
||||
readonly obligationId: string;
|
||||
readonly fixtureId?: string;
|
||||
readonly caseKind?: string;
|
||||
readonly verifierId?: string;
|
||||
readonly claimId?: string;
|
||||
}
|
||||
|
||||
const proofEvidenceAuthorities: Readonly<
|
||||
Record<ProofAuthority, readonly Evidence["authority"][]>
|
||||
> = {
|
||||
unit: ["shipped-artifact", "controlled-replay"],
|
||||
integration: ["controlled-replay"],
|
||||
protocol: ["controlled-replay", "external-service"],
|
||||
renderer: ["controlled-replay", "external-service"],
|
||||
"packaged-process": ["controlled-replay"],
|
||||
"native-abi": ["shipped-artifact", "controlled-replay"],
|
||||
"live-observation": ["controlled-replay", "external-service"],
|
||||
external: ["external-service"],
|
||||
};
|
||||
|
||||
/** Return whether an actual reconstruction proof is comparable to a requirement. */
|
||||
export const proofAuthoritySatisfies = (
|
||||
actual: ProofAuthority,
|
||||
required: ProofAuthority,
|
||||
): boolean => {
|
||||
const accepted: Readonly<Record<ProofAuthority, readonly ProofAuthority[]>> =
|
||||
{
|
||||
unit: [
|
||||
"unit",
|
||||
"integration",
|
||||
"protocol",
|
||||
"renderer",
|
||||
"packaged-process",
|
||||
"native-abi",
|
||||
"live-observation",
|
||||
"external",
|
||||
],
|
||||
integration: [
|
||||
"integration",
|
||||
"protocol",
|
||||
"renderer",
|
||||
"packaged-process",
|
||||
"native-abi",
|
||||
"live-observation",
|
||||
],
|
||||
protocol: ["protocol", "packaged-process", "live-observation"],
|
||||
renderer: ["renderer", "packaged-process", "live-observation"],
|
||||
"packaged-process": ["packaged-process", "live-observation"],
|
||||
"native-abi": ["native-abi"],
|
||||
"live-observation": ["live-observation"],
|
||||
external: ["external"],
|
||||
};
|
||||
return accepted[required].includes(actual);
|
||||
};
|
||||
|
||||
/** Check that Evidence can authenticate the declared reconstruction authority. */
|
||||
export const evidenceAuthoritySupportsProof = (
|
||||
evidence: Evidence,
|
||||
authority: ProofAuthority,
|
||||
expected: ExpectedProof,
|
||||
): boolean => {
|
||||
if (
|
||||
evidence.confidence !== "observed" ||
|
||||
!proofEvidenceAuthorities[authority].includes(evidence.authority) ||
|
||||
evidence.predicate_type !== "rea.reconstruction-proof/v1" ||
|
||||
evidence.operation !== "verify_reconstruction_obligations"
|
||||
)
|
||||
return false;
|
||||
const result = evidence.normalized_result;
|
||||
if (typeof result !== "object" || result === null || Array.isArray(result))
|
||||
return false;
|
||||
const includes = (key: string, value: string | undefined): boolean =>
|
||||
value === undefined ||
|
||||
(Array.isArray(result[key]) && result[key].includes(value));
|
||||
return (
|
||||
result.passed === true &&
|
||||
includes("obligation_ids", expected.obligationId) &&
|
||||
includes("fixture_ids", expected.fixtureId) &&
|
||||
includes("case_kinds", expected.caseKind) &&
|
||||
includes("verifier_ids", expected.verifierId) &&
|
||||
includes("claim_ids", expected.claimId)
|
||||
);
|
||||
};
|
||||
|
||||
/** Check that Evidence observes the original at the required boundary. */
|
||||
export const evidenceSupportsOriginalAuthority = (
|
||||
evidence: Evidence,
|
||||
authority: OriginalAuthority,
|
||||
): boolean => {
|
||||
if (evidence.confidence !== "observed") return false;
|
||||
if (authority === "static") return evidence.authority === "shipped-artifact";
|
||||
if (authority === "runtime")
|
||||
return ["controlled-replay", "external-service"].includes(
|
||||
evidence.authority,
|
||||
);
|
||||
if (authority === "process")
|
||||
return (
|
||||
evidence.operation === "capture_process_scenario" &&
|
||||
evidence.predicate_type === "rea.process-capture/v4"
|
||||
);
|
||||
return evidence.authority === "external-service";
|
||||
};
|
||||
@@ -0,0 +1,416 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import canonicalize from "canonicalize";
|
||||
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import {
|
||||
parseEvidenceBundle,
|
||||
type EvidenceBundle,
|
||||
} from "../domain/evidenceBundle.js";
|
||||
import { parseProcessCapture } from "../domain/processCapture.js";
|
||||
import type {
|
||||
ReconstructionObligation,
|
||||
ReviewedReconstructionObligation,
|
||||
} from "../domain/reconstructionObligationLedgerSchemas.js";
|
||||
import { parseApplicationGraphEvidence } from "./JavaScriptApplicationEvidenceGraph.js";
|
||||
import {
|
||||
applicationObligationPolicy,
|
||||
semanticObligationPolicy,
|
||||
type ReconstructionObligationCandidatePolicy,
|
||||
} from "./ReconstructionObligationPolicies.js";
|
||||
|
||||
type CandidateFields = Omit<
|
||||
ReconstructionObligation,
|
||||
"binding" | "status" | "diagnostics"
|
||||
>;
|
||||
|
||||
/** Internal generated or reviewed obligation before manifest closure evaluation. */
|
||||
export interface ReconstructionObligationCandidate extends CandidateFields {
|
||||
readonly definitionCount: number;
|
||||
readonly disposition: "active" | "blocked" | "out-of-scope";
|
||||
}
|
||||
|
||||
export interface ReconstructionObligationCandidateSet {
|
||||
readonly candidates: readonly ReconstructionObligationCandidate[];
|
||||
readonly limitations: readonly string[];
|
||||
}
|
||||
|
||||
/** Derive conservative candidates from graph, process, and reviewed inputs. */
|
||||
export const deriveReconstructionObligationCandidates = (
|
||||
bundleInput: EvidenceBundle,
|
||||
reviewed: readonly ReviewedReconstructionObligation[],
|
||||
): ReconstructionObligationCandidateSet => {
|
||||
const bundle = parseEvidenceBundle(bundleInput);
|
||||
const records = new Map(
|
||||
bundle.records.map((record) => [record.evidence_id, record]),
|
||||
);
|
||||
const candidates = new Map<string, ReconstructionObligationCandidate>();
|
||||
const limitations = new Set<string>();
|
||||
for (const evidence of bundle.records) {
|
||||
deriveApplicationCandidates(evidence, records, candidates, limitations);
|
||||
deriveProcessCandidate(evidence, candidates, limitations);
|
||||
}
|
||||
for (const obligation of reviewed)
|
||||
addCandidate(candidates, reviewedCandidate(obligation, records));
|
||||
return {
|
||||
candidates: [...candidates.values()].sort((left, right) =>
|
||||
left.obligation_id.localeCompare(right.obligation_id),
|
||||
),
|
||||
limitations: [...limitations].sort(),
|
||||
};
|
||||
};
|
||||
|
||||
const deriveApplicationCandidates = (
|
||||
evidence: Evidence,
|
||||
records: ReadonlyMap<string, Evidence>,
|
||||
candidates: Map<string, ReconstructionObligationCandidate>,
|
||||
limitations: Set<string>,
|
||||
): void => {
|
||||
if (
|
||||
![
|
||||
"analyze_javascript_application",
|
||||
"reconcile_javascript_runtime",
|
||||
"project_managed_application_graph",
|
||||
].includes(evidence.operation)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
const source = parseApplicationGraphEvidence(evidence);
|
||||
for (const node of source.graph.nodes) {
|
||||
const candidatePolicy = applicationObligationPolicy(node.kind);
|
||||
if (candidatePolicy === undefined) continue;
|
||||
const reference = node.observations.map((observation) =>
|
||||
graphReference(
|
||||
evidence,
|
||||
records,
|
||||
observation.evidence,
|
||||
`${source.graph.graph_id}/node/${node.node_id}/observation/${observation.observation_id}`,
|
||||
),
|
||||
);
|
||||
addCandidate(
|
||||
candidates,
|
||||
generatedCandidate({
|
||||
key: `application:${node.node_id}:${candidatePolicy.family}`,
|
||||
title: node.observations[0]?.label ?? `${node.kind} boundary`,
|
||||
policy: candidatePolicy,
|
||||
artifactSha256: source.rootArtifactSha256,
|
||||
applicationNodeId: node.node_id,
|
||||
semanticNodeId: null,
|
||||
location: `application-graph:${source.graph.graph_id}/nodes/${node.node_id}`,
|
||||
authorityReferences: reference,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (source.semanticGraph !== null)
|
||||
for (const node of source.semanticGraph.nodes) {
|
||||
const candidatePolicy = semanticObligationPolicy(node.kind);
|
||||
if (candidatePolicy === undefined) continue;
|
||||
addCandidate(
|
||||
candidates,
|
||||
generatedCandidate({
|
||||
key: `semantic:${node.node_id}:${candidatePolicy.family}`,
|
||||
title: node.label ?? `${node.kind} semantic boundary`,
|
||||
policy: candidatePolicy,
|
||||
artifactSha256: node.identity.artifact_sha256,
|
||||
applicationNodeId: node.application_node_ids[0] ?? null,
|
||||
semanticNodeId: node.node_id,
|
||||
location: `semantic-graph:${source.semanticGraph.graph_id}/nodes/${node.node_id}`,
|
||||
authorityReferences: [
|
||||
graphReference(
|
||||
evidence,
|
||||
records,
|
||||
node.evidence,
|
||||
`${source.semanticGraph.graph_id}/node/${node.node_id}`,
|
||||
),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (source.graph.coverage.status !== "complete")
|
||||
limitations.add(
|
||||
`Application graph ${source.graph.graph_id} coverage is ${source.graph.coverage.status}; omitted candidates remain unknown.`,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
limitations.add(
|
||||
`Application Evidence ${evidence.evidence_id} could not generate candidates: ${errorMessage(cause)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const deriveProcessCandidate = (
|
||||
evidence: Evidence,
|
||||
candidates: Map<string, ReconstructionObligationCandidate>,
|
||||
limitations: Set<string>,
|
||||
): void => {
|
||||
if (
|
||||
evidence.operation !== "capture_process_scenario" ||
|
||||
evidence.predicate_type !== "rea.process-capture/v4"
|
||||
)
|
||||
return;
|
||||
try {
|
||||
const capture = parseProcessCapture(evidence.normalized_result);
|
||||
const observedCases: ReconstructionObligation["observed_cases"] = [
|
||||
{
|
||||
kind: capture.exit.code === 0 ? "positive" : "negative",
|
||||
evidence_id: evidence.evidence_id,
|
||||
location: "/normalized_result/exit",
|
||||
},
|
||||
{
|
||||
kind: "teardown",
|
||||
evidence_id: evidence.evidence_id,
|
||||
location: "/normalized_result/settlement",
|
||||
},
|
||||
...(capture.exit.signal !== null || capture.exit.reason !== "exited"
|
||||
? [
|
||||
{
|
||||
kind: "cancellation" as const,
|
||||
evidence_id: evidence.evidence_id,
|
||||
location: "/normalized_result/exit",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
addCandidate(candidates, {
|
||||
obligation_id: obligationId(
|
||||
`process:${capture.manifest.executable_sha256}:${capture.manifest.full_scenario_sha256}`,
|
||||
),
|
||||
obligation_version: 1,
|
||||
title: "Captured packaged-process lifecycle",
|
||||
origin: "generated",
|
||||
application_layer: "process",
|
||||
family: "packaged-process-lifecycle",
|
||||
target: {
|
||||
artifact_sha256: capture.manifest.executable_sha256,
|
||||
application_node_id: null,
|
||||
semantic_node_id: null,
|
||||
location: `/evidence/${evidence.evidence_id}/normalized_result`,
|
||||
},
|
||||
authority_references: [
|
||||
{
|
||||
evidence_id: evidence.evidence_id,
|
||||
authority: evidence.authority,
|
||||
state: "observed",
|
||||
location: "/normalized_result",
|
||||
},
|
||||
],
|
||||
source_state: "observed",
|
||||
observed_cases: observedCases,
|
||||
required: true,
|
||||
required_case_kinds: ["positive", "negative", "cancellation", "teardown"],
|
||||
required_original_authority: "process",
|
||||
required_fixture_authority: "packaged-process",
|
||||
required_verifier_authority: "packaged-process",
|
||||
requires_parser_type: false,
|
||||
dependency_obligation_ids: [],
|
||||
residual_unknown_ids: [],
|
||||
unavailable_authority: capture.residual_unknowns.map(
|
||||
({ scope, reason }) => `${scope}: ${reason}`,
|
||||
),
|
||||
required_next_evidence: capture.truncated
|
||||
? ["Repeat the packaged-process capture without truncation."]
|
||||
: [],
|
||||
definitionCount: 1,
|
||||
disposition: "active",
|
||||
});
|
||||
if (capture.truncated)
|
||||
limitations.add(
|
||||
`Process Evidence ${evidence.evidence_id} is truncated; absence and upper bounds remain unknown.`,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
limitations.add(
|
||||
`Process Evidence ${evidence.evidence_id} could not generate candidates: ${errorMessage(cause)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
interface GeneratedCandidateInput {
|
||||
readonly key: string;
|
||||
readonly title: string;
|
||||
readonly policy: ReconstructionObligationCandidatePolicy;
|
||||
readonly artifactSha256: string;
|
||||
readonly applicationNodeId: string | null;
|
||||
readonly semanticNodeId: string | null;
|
||||
readonly location: string;
|
||||
readonly authorityReferences: ReconstructionObligation["authority_references"];
|
||||
}
|
||||
|
||||
const generatedCandidate = ({
|
||||
key,
|
||||
title,
|
||||
policy: candidatePolicy,
|
||||
artifactSha256,
|
||||
applicationNodeId,
|
||||
semanticNodeId,
|
||||
location,
|
||||
authorityReferences,
|
||||
}: GeneratedCandidateInput): ReconstructionObligationCandidate => ({
|
||||
obligation_id: obligationId(key),
|
||||
obligation_version: 1,
|
||||
title: title.slice(0, 500),
|
||||
origin: "generated",
|
||||
application_layer: candidatePolicy.applicationLayer,
|
||||
family: candidatePolicy.family,
|
||||
target: {
|
||||
artifact_sha256: artifactSha256,
|
||||
application_node_id: applicationNodeId,
|
||||
semantic_node_id: semanticNodeId,
|
||||
location,
|
||||
},
|
||||
authority_references: authorityReferences,
|
||||
source_state: sourceState(authorityReferences),
|
||||
observed_cases: [],
|
||||
required: true,
|
||||
required_case_kinds: candidatePolicy.cases,
|
||||
required_original_authority: candidatePolicy.originalAuthority,
|
||||
required_fixture_authority: candidatePolicy.fixtureAuthority,
|
||||
required_verifier_authority: candidatePolicy.verifierAuthority,
|
||||
requires_parser_type: candidatePolicy.requiresParserType,
|
||||
dependency_obligation_ids: [],
|
||||
residual_unknown_ids: [],
|
||||
unavailable_authority: [],
|
||||
required_next_evidence: [
|
||||
`Obtain ${candidatePolicy.originalAuthority} authority for this finite ${candidatePolicy.family} claim.`,
|
||||
],
|
||||
definitionCount: 1,
|
||||
disposition: "active",
|
||||
});
|
||||
|
||||
const reviewedCandidate = (
|
||||
obligation: ReviewedReconstructionObligation,
|
||||
records: ReadonlyMap<string, Evidence>,
|
||||
): ReconstructionObligationCandidate => {
|
||||
const {
|
||||
review_evidence_ids: reviewEvidenceIds,
|
||||
disposition,
|
||||
...definition
|
||||
} = obligation;
|
||||
return {
|
||||
...definition,
|
||||
origin: "reviewed",
|
||||
authority_references: reviewEvidenceIds.map((evidenceId) => {
|
||||
const evidence = records.get(evidenceId);
|
||||
if (evidence === undefined)
|
||||
throw new TypeError(
|
||||
`Reviewed obligation Evidence is missing: ${evidenceId}`,
|
||||
);
|
||||
return {
|
||||
evidence_id: evidenceId,
|
||||
authority: evidence.authority,
|
||||
state: reviewedEvidenceState(evidence),
|
||||
location: `/evidence/${evidenceId}`,
|
||||
};
|
||||
}),
|
||||
source_state: "reviewed",
|
||||
observed_cases: [],
|
||||
definitionCount: 1,
|
||||
disposition,
|
||||
};
|
||||
};
|
||||
|
||||
const graphReference = (
|
||||
envelope: Evidence,
|
||||
records: ReadonlyMap<string, Evidence>,
|
||||
graphEvidence: {
|
||||
readonly authority: string;
|
||||
readonly state: string;
|
||||
readonly evidence_ids: readonly string[];
|
||||
},
|
||||
location: string,
|
||||
): ReconstructionObligation["authority_references"][number] => {
|
||||
const linkedId =
|
||||
graphEvidence.evidence_ids.find((evidenceId) => records.has(evidenceId)) ??
|
||||
envelope.evidence_id;
|
||||
const linked = records.get(linkedId) ?? envelope;
|
||||
const runtimeAuthority = [
|
||||
"passive-cdp-runtime",
|
||||
"controlled-replay",
|
||||
].includes(graphEvidence.authority);
|
||||
return {
|
||||
evidence_id: linked.evidence_id,
|
||||
authority: linked.authority,
|
||||
state:
|
||||
runtimeAuthority && graphEvidence.state === "observed"
|
||||
? "observed"
|
||||
: graphEvidence.state === "unknown" ||
|
||||
graphEvidence.state === "unavailable"
|
||||
? "unknown"
|
||||
: "candidate",
|
||||
location,
|
||||
};
|
||||
};
|
||||
|
||||
const reviewedEvidenceState = (
|
||||
evidence: Evidence,
|
||||
): ReconstructionObligation["authority_references"][number]["state"] => {
|
||||
if (
|
||||
evidence.operation === "capture_process_scenario" &&
|
||||
evidence.predicate_type === "rea.process-capture/v4" &&
|
||||
evidence.confidence === "observed"
|
||||
)
|
||||
return "observed";
|
||||
if (
|
||||
evidence.authority === "external-service" &&
|
||||
evidence.confidence === "observed"
|
||||
)
|
||||
return "observed";
|
||||
if (evidence.authority === "shipped-artifact") return "candidate";
|
||||
return "reviewed";
|
||||
};
|
||||
|
||||
const addCandidate = (
|
||||
candidates: Map<string, ReconstructionObligationCandidate>,
|
||||
candidate: ReconstructionObligationCandidate,
|
||||
): void => {
|
||||
const current = candidates.get(candidate.obligation_id);
|
||||
if (current === undefined) {
|
||||
candidates.set(candidate.obligation_id, candidate);
|
||||
return;
|
||||
}
|
||||
candidates.set(candidate.obligation_id, {
|
||||
...current,
|
||||
authority_references: uniqueBy(
|
||||
[...current.authority_references, ...candidate.authority_references],
|
||||
(reference) =>
|
||||
`${reference.evidence_id}:${reference.state}:${reference.location}`,
|
||||
),
|
||||
observed_cases: uniqueBy(
|
||||
[...current.observed_cases, ...candidate.observed_cases],
|
||||
(item) => `${item.kind}:${item.evidence_id}:${item.location}`,
|
||||
),
|
||||
source_state: sourceState([
|
||||
...current.authority_references,
|
||||
...candidate.authority_references,
|
||||
]),
|
||||
definitionCount: current.definitionCount + candidate.definitionCount,
|
||||
});
|
||||
};
|
||||
|
||||
const sourceState = (
|
||||
references: ReconstructionObligation["authority_references"],
|
||||
): ReconstructionObligation["source_state"] => {
|
||||
if (references.some(({ state }) => state === "observed")) return "observed";
|
||||
if (references.some(({ state }) => state === "candidate")) return "candidate";
|
||||
if (references.some(({ state }) => state === "reviewed")) return "reviewed";
|
||||
return "unknown";
|
||||
};
|
||||
|
||||
const uniqueBy = <Value>(
|
||||
values: readonly Value[],
|
||||
key: (value: Value) => string,
|
||||
): Value[] =>
|
||||
[...new Map(values.map((value) => [key(value), value])).values()].sort(
|
||||
(left, right) => key(left).localeCompare(key(right)),
|
||||
);
|
||||
|
||||
const obligationId = (key: string): string =>
|
||||
`obl_${digest({ schema: "rea.reconstruction-obligation/v1", key })}`;
|
||||
|
||||
const digest = (value: unknown): string => {
|
||||
const encoded = canonicalize(value);
|
||||
if (encoded === undefined)
|
||||
throw new TypeError("Reconstruction obligation key is not canonical JSON");
|
||||
return createHash("sha256").update(encoded).digest("hex");
|
||||
};
|
||||
|
||||
const errorMessage = (cause: unknown): string =>
|
||||
cause instanceof Error ? cause.message : "unknown validation failure";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user