Compare commits

...
Author SHA1 Message Date
github-actions[bot] 344b9b6dff chore(main): release rea-agents 3.1.0 (#463)
* chore(main): release rea-agents 3.1.0

* chore: normalize release product catalog

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-10 03:47:06 +08:00
morluto bf04d1aea3 fix(release): let release PR updates trigger CI (#470) 2026-08-10 03:46:53 +08:00
morluto 8b62b1a715 docs(npm): simplify current setup command (#469) 2026-08-10 03:24:24 +08:00
morluto 1783bf6dfd fix(npm): preserve explicit setup versions (#465)
* fix(npm): refresh stale setup bootstraps

* fix(npm): preserve explicit setup versions

* docs: distinguish first-run and refresh setup
2026-08-10 02:59:52 +08:00
morluto ed4a62871f docs: clarify the agent reverse-engineering promise (#466)
docs: clarify agent reverse-engineering promise
2026-08-10 02:46:31 +08:00
morluto b8a9da0c0f refactor(mcp): make analysis states unrepresentable (#468)
* refactor(config): parse capability settings into exact states

* refactor(runtime): encode replay and process outcomes exactly

* refactor: make analysis states unrepresentable

* refactor(mcp): register canonical schemas with SDK

* fix: preserve capability and catalog invariants

* fix(build): remove stale compiled modules

* fix(replay): update real verifier policy dependency

* fix(package): verify the complete MCP catalog

* fix(test): supply complete replay reload policy

* refactor(contracts): centralize output schema lookup

* refactor(mcp): delegate protocol boundaries to SDK

* fix(browser): approve real source capture

* fix(mcp): preserve catalog guidance
2026-08-10 02:28:11 +08:00
morluto 6901edc05d fix: harden structured configuration comparisons (#467)
* refactor(process): split ownership observation helpers

* fix: compare structured configuration canonically

* test(conformance): tolerate Markdown line wrapping

* refactor: name caught values explicitly
2026-08-09 11:58:01 +08:00
morluto 8f229ede70 feat(electron): add autonomous Electron runtime analysis (#464)
* feat: add autonomous Electron runtime analysis

* feat(electron): add lifecycle and boundary telemetry

* feat(electron): capture runtime boundary evidence

* fix(electron): package boundary hook helper

* feat(electron): report active capture coverage

* fix(electron): account for bring-your-own runtime

* feat(electron): expand agent-owned runtime scenarios

* fix(ghidra): keep fixture credentials out of argv

* perf(test): serialize local test and docs workloads

* fix(electron): harden active runtime capture

* fix(inspector): require exact runtime target authorization

* fix(docs): make managed manifest host independent

* fix(ci): make generated catalog portable

* fix: unblock autonomous command and electron flows

* fix(electron): close popup and detached helper gaps
2026-08-05 19:07:46 +08:00
morluto 51559245d2 Merge pull request #461 from morluto/agent/comprehensive-repository-audit
fix: harden evidence lifecycle and MCP contracts
2026-08-04 03:00:25 +08:00
morluto 84cc819284 Merge pull request #462 from morluto/agent/fix-release-please-output
fix(ci): guard Release Please PR branch parsing
2026-08-04 01:56:20 +08:00
morluto 6838b6bff9 fix(ci): guard release PR branch parsing 2026-08-04 01:44:28 +08:00
384 changed files with 12341 additions and 6268 deletions

No files matched your search

+13 -3
View File
@@ -204,6 +204,17 @@ jobs:
if-no-files-found: error
retention-days: 30
conformance-fixtures:
name: Source-built conformance fixtures
needs: changes
if: github.event_name == 'push' || needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Verify fixtures from a clean checkout
run: npm run verify:fixtures
package-e2e:
name: Package E2E (${{ matrix.os }})
needs: changes
@@ -268,7 +279,7 @@ jobs:
run: npm run verify:inspector
windows-curated:
name: Windows Ghidra P0 and package lane
name: Windows capability and package lane
needs: changes
if: github.event_name == 'push' || needs.changes.outputs.package == 'true'
runs-on: windows-latest
@@ -289,7 +300,7 @@ jobs:
node -e "const report = JSON.parse(require('node:fs').readFileSync('windows-capabilities.json', 'utf8'));
if (report.platform !== 'win32' || report.architecture !== 'x64')
throw new Error('expected a Windows x64 capability report');"
- name: Run curated Windows P0 tests
- name: Run curated Windows capability tests
run: >-
npx vitest run --coverage.enabled=false
tests/composition/analysis-sessions/binaryTarget.test.ts
@@ -299,7 +310,6 @@ jobs:
tests/boundary/providers/ghidra/ghidraProvider.test.ts
tests/boundary/providers/ghidra/ghidraTransport.test.ts
tests/boundary/providers/ghidra/ghidraTargetSnapshot.test.ts
tests/boundary/providers/ghidra/ghidraClient.test.ts
tests/boundary/providers/ghidra/ghidraLauncher.test.ts
tests/boundary/process/processOwnership.test.ts
tests/boundary/process/processOwnershipPart2.test.ts
+57 -4
View File
@@ -25,7 +25,9 @@ jobs:
- uses: googleapis/release-please-action@v4
id: release
with:
token: ${{ secrets.GITHUB_TOKEN }}
# A dedicated credential lets release-PR updates trigger CI. GitHub
# requires manual approval for pull_request events from GITHUB_TOKEN.
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
- name: Check out release pull request
if: steps.release.outputs.prs_created == 'true'
@@ -53,9 +55,22 @@ jobs:
if: steps.release.outputs.prs_created == 'true'
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_PR_BRANCH: ${{ fromJSON(steps.release.outputs.pr).headBranchName }}
# Use the same identity for the follow-up commit so it also triggers CI.
GH_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
RELEASE_PR_JSON: ${{ steps.release.outputs.pr }}
run: |
release_pr_branch="$(node --input-type=module -e '
const value = JSON.parse(process.env.RELEASE_PR_JSON ?? "");
if (
typeof value !== "object" ||
value === null ||
typeof value.headBranchName !== "string" ||
value.headBranchName.length === 0
) {
process.exit(1);
}
process.stdout.write(value.headBranchName);
')"
changed_paths="$(git diff --name-only)"
if [[ -z "${changed_paths}" ]]; then
echo "Release documentation is already canonical."
@@ -72,7 +87,7 @@ jobs:
git add -- docs/product-catalog.json
git commit -m "chore: normalize release product catalog"
gh auth setup-git
git push origin "HEAD:${RELEASE_PR_BRANCH}"
git push origin "HEAD:${release_pr_branch}"
publish:
name: Publish to npm
@@ -120,3 +135,41 @@ jobs:
done
echo "Published rea-agents@${version} failed registry and CLI verification" >&2
exit 1
publish-mcp:
name: Publish to MCP Registry
needs: [release-please, publish]
if: needs.release-please.outputs.release_created == 'true' && needs.publish.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.release-please.outputs.tag_name }}
persist-credentials: false
- name: Verify package and registry metadata versions
run: npm run mcp-registry:check
- name: Install MCP Registry publisher
env:
MCP_PUBLISHER_VERSION: v1.8.0
MCP_PUBLISHER_SHA256_LINUX_AMD64: 1370446bbe74d562608e8005a6ccce02d146a661fbd78674e11cc70b9618d6cf
run: |
archive="mcp-publisher_linux_amd64.tar.gz"
curl -fsSLo "${archive}" \
"https://github.com/modelcontextprotocol/registry/releases/download/${MCP_PUBLISHER_VERSION}/${archive}"
echo "${MCP_PUBLISHER_SHA256_LINUX_AMD64} ${archive}" | sha256sum --check --strict
tar xzf "${archive}" mcp-publisher
chmod +x mcp-publisher
- name: Validate MCP Registry metadata
run: ./mcp-publisher validate
- name: Authenticate to MCP Registry
run: ./mcp-publisher login github-oidc
- name: Publish MCP Registry metadata
run: ./mcp-publisher publish
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.0.0"
".": "3.1.0"
}
+9
View File
@@ -54,6 +54,7 @@ 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:local`: run only changed and related tests without rebuilding `dist/`; use this for the lightweight local loop.
- `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.
@@ -85,6 +86,14 @@ See [docs/architecture.mermaid](docs/architecture.mermaid) for a visual architec
- `npm run docs:api:cached`: render API documentation through Turbo or restore a valid local result.
- `npm run docs:generate`: refresh committed metadata and render the uncommitted API documentation.
- `npm run docs:check`: verify generated package metadata, the canonical product catalog, caller-visible documentation facts, TypeDoc rendering, and the error JSON schema.
Local full-suite Vitest runs use one worker and serial project groups because
boundary tests own subprocess fixtures. CI retains the existing two-worker
budget. The `test`, `docs:check`, and `docs:generate` commands use
repository-local locks and refuse overlapping runs; interrupted commands can
leave a stale lock that is removed automatically when its owner PID is no
longer alive. The `npm test` build runs inside that lock.
- `npm run config:print -- /path/to/binary`: print an MCP server config with absolute paths.
- `HOPPER_TARGET_PATH=/path/to/binary npm start`: launch Hopper and run the built stdio MCP server.
+28
View File
@@ -1,5 +1,33 @@
# Changelog
## [3.1.0](https://github.com/morluto/rea/compare/rea-agents-3.0.0...rea-agents-3.1.0) (2026-08-09)
### Features
* **electron:** add autonomous Electron runtime analysis ([#464](https://github.com/morluto/rea/issues/464)) ([8f229ed](https://github.com/morluto/rea/commit/8f229ede70c0a4e224c1cf71d61487e17b779d6a))
### Bug Fixes
* **ci:** guard Release Please PR branch parsing ([84cc819](https://github.com/morluto/rea/commit/84cc819284b9ce7ee648850c85d5daf3a33e5a24))
* **ci:** guard release PR branch parsing ([6838b6b](https://github.com/morluto/rea/commit/6838b6bff9ab5c400432caefd8c8737b86c41f07))
* harden evidence lifecycle and MCP contracts ([5155924](https://github.com/morluto/rea/commit/51559245d2e1c2d7550cfe489716950fe6154bd0))
* harden structured configuration comparisons ([#467](https://github.com/morluto/rea/issues/467)) ([6901edc](https://github.com/morluto/rea/commit/6901edc05d56063bdbe8be96511b1b21863630b5))
* **npm:** preserve explicit setup versions ([#465](https://github.com/morluto/rea/issues/465)) ([1783bf6](https://github.com/morluto/rea/commit/1783bf6dfdd4ec11e824870ce5b1a2f5a321450e))
### Code Refactoring
* **mcp:** make analysis states unrepresentable ([#468](https://github.com/morluto/rea/issues/468)) ([b8a9da0](https://github.com/morluto/rea/commit/b8a9da0c0f9f0b2b022a350d2c7c671d1fa3f886))
### Documentation
* clarify agent reverse-engineering promise ([ed4a628](https://github.com/morluto/rea/commit/ed4a62871f7924948e3774ae6cf880514a560ff4))
* clarify the agent reverse-engineering promise ([#466](https://github.com/morluto/rea/issues/466)) ([ed4a628](https://github.com/morluto/rea/commit/ed4a62871f7924948e3774ae6cf880514a560ff4))
* **npm:** simplify current setup command ([#469](https://github.com/morluto/rea/issues/469)) ([8b62b1a](https://github.com/morluto/rea/commit/8b62b1a71586e9409e4dd645f3d4947ef61e59b1))
## [3.0.0](https://github.com/morluto/rea/compare/rea-agents-2.7.0...rea-agents-3.0.0) (2026-08-01)
+14 -16
View File
@@ -4,7 +4,7 @@
# REA: Reverse Engineer Anything
### One CLI and MCP server for agents to reverse engineer anything
### Reverse engineer anything with agents, from app behavior down to native binaries.
**See a feature you like. Understand how it works, down to the binary level.**
@@ -89,13 +89,13 @@ REA shows how it reached its conclusions. It does not claim to recover original
### Run setup — recommended
```bash
npx rea-agents setup
npx --yes rea-agents@latest setup
```
The npm package-runner prompt, when shown, approves downloading REA for this
invocation; it does not approve any setup changes. The REA wizard separately
shows its complete plan and asks before applying it. Setup does not update
Homebrew, Node.js, or npm. `npx rea-agents setup` opens with the work it
Homebrew, Node.js, or npm. The setup command opens with the work it
enables: investigate local apps from an agent, recover evidence through a
deep-analysis provider, and reuse REA's guided workflow. It summarizes the
detected agents, then asks which capabilities to set up: agent integration
@@ -103,12 +103,10 @@ detected agents, then asks which capabilities to set up: agent integration
Nothing is preselected. Choosing agent integration opens a second empty
checklist for the specific detected agents that should receive a registration.
If bare `npx` finds a project-local REA package, its dispatcher restarts setup
through `rea-agents@latest` before planning any changes. This prevents an older
local dependency from silently writing stale integration state. An already
published older release cannot contain that dispatcher protection; run
`npx -y rea-agents@latest setup` once to repair registrations created by one.
Intentional rollbacks remain available through an exact package request.
`@latest` makes the requested release explicit and asks npm for the release
currently published under that tag. REA does not silently replace the package
version npm selected. Intentional rollbacks therefore remain available through
an exact package request.
REA keeps the journey inline so its history remains in the terminal. Selecting
a capability does not select every detected target or authorize a change.
@@ -139,7 +137,7 @@ Pass installer options after `bash -s --`, for example `--dry-run`, `--no-setup`
### With an agent — recommended
```bash
npx rea-agents setup
npx --yes rea-agents@latest setup
```
Choose Agent Integration in the reviewed setup plan. REA installs the pinned MCP
@@ -151,7 +149,7 @@ Review the setup plan, approve it if appropriate, then describe the app or featu
### From Terminal — no installation
```bash
npx rea-agents setup
npx --yes rea-agents@latest setup
npx -y rea-agents@latest doctor
npx -y rea-agents@latest analyze /Applications/Notes.app
```
@@ -386,10 +384,10 @@ REA handles the app analysis in steps 1–5. The agent performs step 6 with its
| 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 |
| Electron analysis | 5 | passive root-confined observation, bounded static application mapping, evidence-backed static/runtime reconciliation, and separately approved provider-owned click/wait scenarios |
| 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 | 22 | target lifecycle, Evidence v2 bundle snapshots, aggregate navigation/address context, direct finite replay-machine evaluation, process/artifact/function comparison, evidence-linked residual-unknown lifecycle |
| Workspace and observation | 23 | target lifecycle, Evidence v2 bundle snapshots, retained-bundle release, aggregate navigation/address context, 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.
@@ -509,14 +507,14 @@ REA is growing into a toolkit for understanding software across static artifacts
### Next
1. **Controlled replay conformance growth** — extend the shipped Linux extracted-module sandbox with more source-owned hostile fixtures and cross-kernel conformance; browser or Electron interaction remains a different future authority.
1. **Controlled replay conformance growth** — extend the shipped Linux extracted-module sandbox with more source-owned hostile fixtures and cross-kernel conformance; browser and Electron scenarios remain separately permissioned authorities.
2. **Broader application graph evidence** — extend authenticated cross-layer traces with additional static extractors and separately approved runtime authorities.
3. **Professional managed-code analysis** — extend shipped PE/CLI triage, CIL evidence, managed/native declaration inventory, source-owned conformance, and obfuscation-resistant comparisons toward verified native-provider composition under the accepted [managed-code boundary](docs/managed-code-analysis.md).
4. **Deterministic behavior harnesses** — extend process ownership, protocol fixtures, filesystem observation, reconnects, and cross-version behavioral comparison.
### Later
1. **Controlled application interaction** — evaluate separately authorized full-application driving without widening passive browser, Electron, or extracted-module replay authority.
1. **Broader controlled application interaction** — extend the separately authorized browser and Electron scenario surfaces beyond the current bounded click/wait actions without widening passive observation or extracted-module replay authority.
2. **Native runtime observation** — approval-gated LLDB, Frida, system logs, process/filesystem observers, and native API tracing.
3. **Additional providers and targets** — evaluate IDA/Hex-Rays, Binary Ninja, Rizin, LIEF, Windows-native providers, mobile artifacts, firmware, document formats, and other software-defined systems.
@@ -537,7 +535,7 @@ Setup detects Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf,
"mcpServers": {
"rea": {
"command": "npx",
"args": ["-y", "rea-agents@3.0.0", "mcp"]
"args": ["-y", "rea-agents@3.1.0", "mcp"]
}
}
}
+5 -5
View File
@@ -65,7 +65,7 @@ npx rea-agents setup
### باستخدام وكيل برمجة — موصى به
```bash
npx rea-agents setup
npx --yes rea-agents@latest setup
```
اطلب من الوكيل إعداد REA. سيفحص جهاز Mac ويشرح ما يحتاج إلى تثبيته ويطلب الموافقة ويرشدك خلال رسائل النظام. أعد تشغيل الوكيل بعد الإعداد إذا طلب ذلك لتحميل مجموعة الأدوات الكاملة.
@@ -90,7 +90,7 @@ npx rea-agents setup
```bash
curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash
npx rea-agents setup
npx --yes rea-agents@latest setup
```
إذا طلب macOS أو برنامج التثبيت تأكيدًا، فأكمل الخطوة ثم شغّل الأمر نفسه مرة أخرى.
@@ -167,10 +167,10 @@ rea uninstall --purge-data
| رسم القطع الأثرية | 3 | جرد حتمي للدلائل وZIP/APK/IPA وASAR واستخراج معاملاتي محدد صراحة |
| Managed PE/CLI | 8 | هوية PE/CLI، أعضاء البيانات الوصفية، بصمات CIL، تصريحات P/Invoke/الحدود الأصلية والتحقق منها، إسقاط رسم التطبيق، استيراد إعادة البناء من فك الترجمة، إعادة ربط الرموز، خطط ترابط وقت التشغيل ومقارنة الإصدارات |
| مراقبة المتصفح | 9 | التقاط CDP محدود الأصل وتحليل الحزم وخرائط المصدر واكتشاف WebMCP والفروق والأدلة المرئية |
| تحليل Electron | 4 | مراقبة سلبية ضمن جذور canonical، ورسم ثابت ومحدود للتطبيق، ومطابقة ثابتة/وقت تشغيل قائمة على Evidence |
| تحليل Electron | 5 | مراقبة سلبية ضمن جذور canonical، ورسم ثابت ومحدود للتطبيق، ومطابقة ثابتة/وقت تشغيل قائمة على Evidence |
| وقت تشغيل JavaScript | 2 | اكتشاف أهداف Node/Electron Inspector المعتمدة للاتصال فقط، ومراقبة محدودة للبرامج النصية وسياقات التنفيذ دون تقييم أو تجهيز |
| سير عمل التطبيقات | 12 | تتبع محدود ومقارنة إصدارات فريدة ومقارنة ثابتة لأشكال قيم إرجاع الصادرات وإعادة تشغيل وحدات معزولة ومعتمدة على Linux وتوصيف وقت التشغيل المُدار وإغلاق تغطية إعادة البناء ودفاتر التزامات حتمية والتحقق الشامل من جاهزية إعادة البناء |
| جلسة الملف التنفيذي | 22 | فتح الأهداف وحزم الأدلة وتشغيل آلة إعادة محدودة ومقارنة العمليات والقطع والدوال وسجل المجهولات المتبقية |
| جلسة الملف التنفيذي | 23 | فتح الأهداف وحزم الأدلة وتحرير الحزم المحتفظ بها وتشغيل آلة إعادة محدودة ومقارنة العمليات والقطع والدوال وسجل المجهولات المتبقية |
## استخدام REA مع وكلاء برمجة آخرين
@@ -183,7 +183,7 @@ rea uninstall --purge-data
"mcpServers": {
"rea": {
"command": "npx",
"args": ["-y", "rea-agents@3.0.0", "mcp"]
"args": ["-y", "rea-agents@3.1.0", "mcp"]
}
}
}
+5 -5
View File
@@ -67,7 +67,7 @@ REA は調査をバイナリ上の根拠に結び付けます。元のソース
### コーディングエージェントから(推奨)
```bash
npx rea-agents setup
npx --yes rea-agents@latest setup
```
エージェントに REA のセットアップを依頼してください。Mac を確認し、必要なインストールを説明して承認を求め、システムプロンプトを案内します。完全なツールを読み込むため再起動を求められた場合は、セットアップ後に再起動してください。
@@ -91,7 +91,7 @@ Ubuntu 24.04+、Fedora 41+、64 ビット Arch Linux では、REA が公式の D
```bash
# 1. REA をセットアップ
curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash
npx rea-agents setup
npx --yes rea-agents@latest setup
```
macOS やインストーラーから確認を求められた場合は、その操作を完了してから同じコマンドをもう一度実行してください。
@@ -157,10 +157,10 @@ REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェ
| アーティファクトグラフ | 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 に基づく静的/実行時の照合 |
| Electron 解析 | 5 | canonical ファイルルート内の受動観察、有界な静的アプリマッピング、Evidence に基づく静的/実行時の照合 |
| JavaScript ランタイム | 2 | 承認済みのアタッチ専用 Node/Electron Inspector ターゲット検出と、評価や計装を行わない有界なスクリプト/実行コンテキスト観察 |
| アプリケーション | 12 | 有界なクロスレイヤー追跡、一意限定のバージョン比較、静的なエクスポート戻り値形状比較、承認済み Linux 隔離 extracted-module replay、マネージドランタイム特性評価、再構築カバレッジ閉包、決定論的な義務台帳、エンドツーエンドの準備適合性検証 |
| バイナリセッション | 22 | `open_binary`、`binary_session`、証拠バンドル、有限リプレイ状態機械、プロセス/成果物/関数比較、残余未知レジストリ |
| バイナリセッション | 23 | `open_binary`、`binary_session`、証拠バンドル、保持バンドルの解放、有限リプレイ状態機械、プロセス/成果物/関数比較、残余未知レジストリ |
## 他のコーディングエージェントで使う
@@ -173,7 +173,7 @@ Setup は Claude Code、Claude Desktop、Codex、Cursor、Gemini CLI、Windsurf
"mcpServers": {
"rea": {
"command": "npx",
"args": ["-y", "rea-agents@3.0.0", "mcp"]
"args": ["-y", "rea-agents@3.1.0", "mcp"]
}
}
}
+5 -5
View File
@@ -67,7 +67,7 @@ REA는 분석을 바이너리 증거에 근거하게 합니다. 원본 소스
### 코딩 에이전트에서 시작하기(권장)
```bash
npx rea-agents setup
npx --yes rea-agents@latest setup
```
에이전트에게 REA 설정을 요청하세요. Mac을 확인하고 필요한 설치 내용을 설명한 뒤 승인을 요청하며 시스템 프롬프트를 안내합니다. 전체 도구를 불러오기 위해 재시작하라고 하면 설정 후 에이전트를 재시작하세요.
@@ -91,7 +91,7 @@ Ubuntu 24.04+, Fedora 41+, 64비트 Arch Linux에서 REA는 공식 DEB, RPM 또
```bash
# 1. REA 설정
curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash
npx rea-agents setup
npx --yes rea-agents@latest setup
```
macOS나 설치 프로그램이 확인을 요청하면 해당 절차를 완료하고 같은 명령을 다시 실행하세요.
@@ -157,10 +157,10 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이
| 아티팩트 그래프 | 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 기반 정적/런타임 조정 |
| Electron 분석 | 5 | canonical 파일 루트 내 수동 관찰, 제한된 정적 앱 매핑, Evidence 기반 정적/런타임 조정 |
| JavaScript 런타임 | 2 | 승인된 연결 전용 Node/Electron Inspector 대상 탐색과 평가·계측 없는 제한된 스크립트 및 실행 컨텍스트 관찰 |
| 애플리케이션 | 12 | 제한된 교차 계층 추적, 고유 일치 버전 비교, 정적 export 반환 형태 비교, 승인된 Linux 격리 extracted-module replay, 관리형 런타임 특성화, 재구성 커버리지 폐쇄, 결정론적 의무 원장, 엔드투엔드 준비 적합성 검증 |
| 바이너리 세션 | 22 | `open_binary`, `binary_session`, 증거 번들, 유한 replay 상태 머신, 프로세스·아티팩트·함수 비교, 잔여 미지수 레지스트리 |
| 바이너리 세션 | 23 | `open_binary`, `binary_session`, 증거 번들, 보존 번들 해제, 유한 replay 상태 머신, 프로세스·아티팩트·함수 비교, 잔여 미지수 레지스트리 |
## 다른 코딩 에이전트에서 사용하기
@@ -173,7 +173,7 @@ Setup은 Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf, Devin
"mcpServers": {
"rea": {
"command": "npx",
"args": ["-y", "rea-agents@3.0.0", "mcp"]
"args": ["-y", "rea-agents@3.1.0", "mcp"]
}
}
}
+5 -5
View File
@@ -67,7 +67,7 @@ REA 让调查始终以二进制证据为依据。它不会声称能恢复原始
### 使用编程智能体(推荐)
```bash
npx rea-agents setup
npx --yes rea-agents@latest setup
```
让智能体设置 REA。它会检查你的 Mac,说明需要安装的内容,先征得同意,并引导你处理系统提示。如果智能体要求重启以加载完整工具,请在设置后重启。
@@ -91,7 +91,7 @@ npx rea-agents setup
```bash
# 1. 安装和配置 REA
curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash
npx rea-agents setup
npx --yes rea-agents@latest setup
```
如果 macOS 或安装程序要求确认,请完成提示,然后再次运行同一命令。
@@ -157,10 +157,10 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
| 制品图谱 | 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 的静态/运行时对账 |
| Electron 分析 | 5 | 限定 canonical 文件根的被动观察、有界静态应用映射,以及基于 Evidence 的静态/运行时对账 |
| JavaScript 运行时 | 2 | 经批准的只附加 Node/Electron Inspector 目标发现,以及不执行求值或插桩的有界脚本和执行上下文观察 |
| 应用工作流 | 12 | 有界跨层追踪、唯一匹配跨版本图比较、静态导出返回形状比较、经批准的 Linux 隔离 extracted-module replay、托管运行时表征、重建覆盖闭合、确定性义务账本和端到端就绪性一致性验证 |
| 二进制会话 | 22 | `open_binary`、`binary_session`、证据包、有限重放状态机、进程、制品与函数比较、残余未知项注册表 |
| 二进制会话 | 23 | `open_binary`、`binary_session`、证据包、保留包释放、有限重放状态机、进程、制品与函数比较、残余未知项注册表 |
## 与其他编程智能体一起使用
@@ -173,7 +173,7 @@ Setup 会检测 Claude Code、Claude Desktop、Codex、Cursor、Gemini CLI、Win
"mcpServers": {
"rea": {
"command": "npx",
"args": ["-y", "rea-agents@3.0.0", "mcp"]
"args": ["-y", "rea-agents@3.1.0", "mcp"]
}
}
}
+52
View File
@@ -57,3 +57,55 @@ work completed.
Inspection does not retain DOM values, execute renderer code, navigate, click,
invoke Electron IPC, close a target, or terminate the application.
## Active Electron scenarios
Active Electron authority is separate and disabled by default. When enabled,
REA launches an operator-approved Electron executable through the official
Playwright Electron API, owns its lifetime, accepts bounded click/wait actions,
window-targeted renderer reload/crash actions, and synthetic `open-url` or
`second-instance` deep-link delivery. It records capture-scoped window and
WebContents identities, process metrics, and IPC channel and value-shape metadata.
Payload values are not retained.
Configure exact roots and run the real fixture verifier with an operator-owned
Electron runtime:
```bash
export REA_ELECTRON_AUTOMATE_ENABLED=true
export REA_ELECTRON_AUTOMATE_AUTO_GRANT=false
export REA_ELECTRON_AUTOMATE_EXECUTABLE_ROOTS_JSON='["/absolute/path/to/runtime"]'
export REA_ELECTRON_AUTOMATE_APPLICATION_ROOTS_JSON='["/absolute/path/to/app"]'
REA_ELECTRON_EXECUTABLE=/absolute/path/to/electron npm run verify:electron
```
This capability actively launches and interacts with the target. It is not a
passive CDP observation and must be granted separately as `electron_automate`.
The default is fail-closed (`AUTO_GRANT=false`); an operator can instead issue
a project/session/one-shot grant through the normal permission workflow. The
owned process keeps normal host filesystem and network privileges, so this is
an authority boundary and lifecycle boundary, not a sandbox.
The CLI and MCP surfaces accept the same schema. For a JSON request file:
```bash
rea capture-electron-scenario scenario.json --json
```
The result records bounded action status and targets, correlated app/window/WebContents,
preload, session, navigation, shell, permission, popup, download, protocol,
native-addon, process, and IPC timeline events. IPC channels are capped at
1,024 characters and argument-shape metadata at 32 entries; values are never
retained. Renderer crash/restart and deep-link actions are synthetic, bounded
scenario controls; their attempted and observed outcomes remain in the
timeline. The active hook blocks and records external shell/navigation,
permission, download, popup, updater, and OS-integration effects. The timeline
is explicitly partial when attachment starts after application activity or when
the event budget is exhausted.
Active Electron Evidence can also be supplied to
[`reconcile_javascript_runtime`](javascript-runtime-reconciliation.md). That
projection is intentionally target-only and partial: it binds the approved
application path and capture outcome to the static graph, while frames, scripts,
workers, and execution claims remain unavailable until a separate passive runtime
capture provides them.
+35 -10
View File
@@ -7,7 +7,7 @@ REA separates installing its CLI from configuring external software and agents.
The recommended setup entrypoint is:
```bash
npx rea-agents setup
npx --yes rea-agents@latest setup
```
If npm asks to download and run the package, that approval applies only to the
@@ -15,13 +15,11 @@ current package-runner invocation. REA still prints its own setup plan and asks
for separate approval before changing agent configuration or installing a
product-owned component.
When bare `npx` resolves `rea-agents` from the current project's
`node_modules`, the selected dispatcher restarts setup through
`rea-agents@latest` before REA plans or writes configuration. This protects
current and future releases from silently persisting stale integration state.
Code in an already-published older release cannot acquire that check
retroactively; invoke `npx -y rea-agents@latest setup` once to repair any
registration it created.
The explicit `@latest` request prevents a project dependency or stale npx cache
entry from choosing the setup version.
REA runs the version npm selected and never silently replaces an intentional
version request after launch. To repair registrations written by an older
release, rerun the command above and review the resulting setup plan.
For an intentional rollback, make the package request explicit:
@@ -37,8 +35,8 @@ REA supports Node.js 22.19+ and 24.11+ (including newer releases). It uses the n
Running `npm install rea-agents` without `--global` installs the executable only
in the current project's `node_modules/.bin`; it does not make `rea` available
on the shell `PATH`. Use `npx rea-agents setup` for the guided setup journey,
`npx -y rea-agents@latest` for unattended one-off commands, or install globally
on the shell `PATH`. Use the explicit setup command above for the guided setup
journey, `npx -y rea-agents@latest` for unattended one-off commands, or install globally
with `npm install --global rea-agents` for a shell-visible `rea` command.
The optional curl wrapper installs only the global npm package:
@@ -259,3 +257,30 @@ target/snapshot/import digest identity, and requires complete runtime cleanup.
## Diagnose, update, and remove
`rea doctor --json` is strictly read-only. `rea upgrade` updates only the npm installation that owns the running CLI. `rea uninstall` removes only REA-owned agent registrations and skill files; `--purge-data` additionally removes REA cache and state paths.
## MCP Registry
REA is published in the official MCP Registry as `io.github.morluto/rea`. Registry
clients can discover the server and install the existing public `rea-agents` npm
package; the Registry entry does not introduce a second distribution artifact.
For a client that supports Registry discovery, search for `io.github.morluto/rea`
and select the npm package. The published metadata launches the existing stdio
server through `npx` with the `mcp` command.
For a client that requires manual configuration, use:
```json
{
"mcpServers": {
"rea": {
"command": "npx",
"args": ["-y", "rea-agents@latest", "mcp"]
}
}
}
```
Use an exact `rea-agents@VERSION` in the arguments when a reproducible client
configuration is required. `rea setup` writes the same package-runner shape and
pins it to the exact version that performed setup.
+11 -2
View File
@@ -87,6 +87,12 @@ workspace `resource_link` blocks. Workspace resources preserve revision and
`previous_revision_digest` commitments; persistent workspace files remain
subject to configured read/write roots.
Retained bundle URIs are session-scoped and are invalidated by
`release_evidence_bundle` or session close. When retention reaches its bounded
capacity, release an unneeded digest and retry; for cross-session recovery use
`export_evidence_bundle` followed by `import_evidence_bundle` and
`snapshot_evidence_bundle` in the new session.
`export_evidence_bundle` is file-only and requires `path`; existing files
require `overwrite: true`. Use `snapshot_evidence_bundle` and then
`resources/read` when the complete bundle is needed within the current session.
@@ -98,8 +104,11 @@ explicitly retains them.
## Aggregate native context
`get_navigation_context` combines the selected document, current address, and
current/containing procedure. A cursor outside a procedure is represented as
`get_navigation_context` composes the selected document, current address, and
current/containing procedure. Its capability inventory exposes a
`current_selection` mode and an `explicit_document` mode; the latter works when
the caller supplies `document` and the provider lacks `current_document`. A
cursor outside a procedure is represented as
`procedure: null`. `inspect_address_context` requires an explicit address and
returns bounded name, procedure, comment, inline-comment, and bookmark facets;
unsupported facets are local `unavailable` outcomes. The scalar getters remain
+5 -3
View File
@@ -47,9 +47,11 @@ REA offers the once-or-session grant form only after the connection negotiates
an MCP revision with multi-round tool results and advertises form elicitation.
Older connections keep returning the structured
permission-required result and never wait for an unsupported elicitation
response. The MCP SDK runtime currently pinned by REA negotiates the older
protocol path, so the interactive flow remains fail-closed until its runtime
protocol support is available.
response. On the pinned `@modelcontextprotocol/server` 2.0.0 runtime, this is
the official `2026-07-28` `inputRequired` continuation: the SDK seals the
request state and validates the continuation envelope, while REA validates the
untrusted input response before creating a connection-owned grant. The older
push-style `elicitation/create` path is not used for this protocol revision.
Every scenario must also contain `"approved": true`. Executables, working
directories, filesystem roots, and requested environment variables are checked
+19 -10
View File
@@ -2,7 +2,7 @@
"catalog_schema_version": 1,
"package": {
"name": "rea-agents",
"version": "3.0.0",
"version": "3.1.0",
"sdk": {
"server": "2.0.0",
"client": "2.0.0",
@@ -11,7 +11,7 @@
"skill_version": "23"
},
"tools": {
"total": 114,
"total": 116,
"families": [
{
"id": "direct",
@@ -128,9 +128,10 @@
{
"id": "electron",
"surface": "electron-provider",
"count": 4,
"count": 5,
"tools": [
"analyze_javascript_application",
"capture_electron_scenario",
"inspect_electron_page",
"list_electron_targets",
"reconcile_javascript_runtime"
@@ -167,7 +168,7 @@
{
"id": "session",
"surface": "session",
"count": 22,
"count": 23,
"tools": [
"binary_session",
"build_call_path",
@@ -186,6 +187,7 @@
"list_unknowns",
"open_binary",
"record_unknown",
"release_evidence_bundle",
"run_replay_machine",
"snapshot_evidence_bundle",
"update_unknown",
@@ -368,6 +370,12 @@
"version": "1",
"capabilities": ["capture_browser_scenario"]
},
{
"id": "rea-playwright-electron-active",
"name": "REA Playwright active Electron observation provider",
"version": "1",
"capabilities": ["capture_electron_scenario"]
},
{
"id": "rea-v8-inspector",
"name": "REA passive Node/Electron V8 Inspector provider",
@@ -585,7 +593,7 @@
}
],
"cli": {
"primary_count": 67,
"primary_count": 68,
"commands": [
"analyze",
"analyze-javascript-application",
@@ -593,6 +601,7 @@
"build-reconstruction-obligation-ledger",
"capabilities",
"capture-browser-scenario",
"capture-electron-scenario",
"capture-process",
"capture-web-screenshot",
"commit-reconstruction-coverage",
@@ -664,18 +673,18 @@
},
"runtime_catalog": {
"counts": {
"cli_commands": 67,
"mcp_tools": 114,
"cli_commands": 68,
"mcp_tools": 116,
"mcp_prompts": 6,
"mcp_resources": 2,
"mcp_resource_templates": 11
},
"digests": {
"tools_sha256": "58bc4f72c5f8de4328728ead9f3a28ec5548484b7b9cf2ed85cf0edcc689a63c",
"tools_sha256": "b0e8b9c894ef347b6680a3a5bfdb4cba5c10e9129eeaa43a5ea22ce4d398d417",
"prompts_sha256": "3886a3bf55a448def79e3eb429e6a56eb2ba075423c5cb1e5747642b389ef8fe",
"resources_sha256": "e0e872276ebf6f88e916ef24930a99eed27ce4c93e494e60820e9bd8cc8497c3",
"combined_sha256": "e83a177f5eca63bbad17886a61f5a1a376d44afe862c51c56e5d38339f7d41f8",
"providers_sha256": "918ccd6b8ae442cd5844dcfde55086f458176241c8d166c1e9dd0f242ccd2bb0"
"combined_sha256": "bab5657d9525ec4f798d0a9ef6001412d9c11f77fb0ddc925c7076fd10921dc9",
"providers_sha256": "7446b51788734ebf6f4f702e3a5a8c9f0b61967490c245f30bd37b2060827e6b"
}
}
}
+30 -18
View File
@@ -6,14 +6,15 @@ allowed dependencies, and runtime cost are visible from their paths.
## Behavioral depths
| Depth | Location | What it proves |
| ----------- | ---------------------- | ----------------------------------------------------------------------------------------------------- |
| Module | `src/**/*.test.ts` | One domain, service, or adapter through its narrow public surface |
| Composition | `tests/composition/**` | Provider-neutral session and registry wiring; filesystem access is limited to fixture materialization |
| Boundary | `tests/boundary/**` | Exactly one production filesystem, process, network, browser, CLI, MCP, or provider boundary |
| Acceptance | `tests/acceptance/**` | A complete compiled CLI or MCP workflow |
| Conformance | `tests/conformance/**` | Shared provider contracts, parameterized by declared capabilities and explicit opt-outs |
| Evaluation | `tests/evaluation/**` | Deterministic evaluator parsing, scoring, and report generation |
| Depth | Location | What it proves |
| ------------ | ----------------------- | ----------------------------------------------------------------------------------------------------- |
| Module | `src/**/*.test.ts` | One domain, service, or adapter through its narrow public surface |
| Composition | `tests/composition/**` | Provider-neutral session and registry wiring; filesystem access is limited to fixture materialization |
| Boundary | `tests/boundary/**` | Exactly one production filesystem, process, network, browser, CLI, or provider boundary |
| MCP boundary | `tests/boundary/mcp/**` | MCP transport and tool-contract boundaries with isolated sessions and explicit cleanup |
| Acceptance | `tests/acceptance/**` | A complete compiled CLI or MCP workflow |
| Conformance | `tests/conformance/**` | Shared provider contracts, parameterized by declared capabilities and explicit opt-outs |
| Evaluation | `tests/evaluation/**` | Deterministic evaluator parsing, scoring, and report generation |
Colocated domain tests may import only their owning domain and inward
dependencies. They do not construct sessions or servers and do not start
@@ -42,22 +43,33 @@ manual; Vitest covers only deterministic evaluator logic.
`npm test` runs every deterministic project once. The narrower feedback loops
are:
| Command | Scope |
| ------------------------- | -------------------------------------------------------------------------------------------------------- |
| `npm run test:fast` | Domain, service, adapter, composition, boundary, and conformance projects |
| `npm run test:boundary` | Boundary and process-global projects |
| `npm run test:acceptance` | Complete CLI and MCP acceptance workflows |
| `npm run test:changed` | Changed tests in non-serial projects via Vitest's import graph |
| `npm run test:watch` | Changed domain, service, adapter, composition, boundary, conformance, and evaluation tests in watch mode |
| `npm run test:watch:all` | Changed tests from every deterministic project in watch mode |
| `npm run check:changed` | Cached static checks followed by changed tests |
| `npm run check:pr` | Static, generated-document, and complete deterministic PR gate |
| Command | Scope |
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `npm run test:fast` | Domain, service, adapter, composition, boundary, MCP boundary, and conformance projects |
| `npm run test:local` | Changed and related tests without the build step; lightweight local feedback loop |
| `npm run test:boundary` | Boundary, MCP boundary, and process-global projects |
| `npm run test:mcp` | MCP boundary project only |
| `npm run test:acceptance` | Complete CLI and MCP acceptance workflows |
| `npm run test:changed` | Changed tests in non-serial projects via Vitest's import graph |
| `npm run test:watch` | Changed domain, service, adapter, composition, boundary, MCP boundary, conformance, and evaluation tests in watch mode |
| `npm run test:watch:all` | Changed tests from every deterministic project in watch mode |
| `npm run check:changed` | Cached static checks followed by changed tests |
| `npm run check:pr` | Static, generated-document, and complete deterministic PR gate |
Changed-test selection is a fast feedback aid, not release evidence. It can
miss behavior connected through runtime registration, generated data, shell
entrypoints, or other relationships that are absent from the import graph.
Use `npm run check:pr` before handing off a contribution.
Local full-suite Vitest runs are intentionally capped at one worker and one
project at a time. Boundary fixtures own real subprocesses, and this local cap
keeps aggregate memory predictable; CI retains the existing two-worker budget.
`npm test`, `npm run docs:check`, and `npm run docs:generate` share
repository-local locks and fail fast when the same class of command is already
running. The `npm test` build is inside that lock. `check:pr` runs its test task
before starting documentation validation, so TypeDoc does not compete with the
full suite for memory.
Vitest and Node persistent compile caches are deliberately not enabled by
default. To evaluate repeated local runs, opt in for both cold and warm
measurements with an isolated cache:
@@ -1,20 +1,20 @@
{
"schema_version": 1,
"manifest_id": "ecm_c1609d2cc959477e200df1a368ade5e3d68ad6fd4041009b78a5778a3b76e9bc",
"manifest_id": "ecm_c44391ed8e365e21871664422aa38fa6c12598006cde731d65a9d9e271b0fa5f",
"verifier": {
"id": "rea-managed-conformance",
"version": "1"
},
"environment": {
"platform": "linux",
"architecture": "x64",
"platform": "portable",
"architecture": "portable",
"runtime": "node",
"runtime_version": "24"
"runtime_version": "portable"
},
"skill_digests": [
{
"skill_id": "reverse-engineer-anything",
"sha256": "1398947e9119a5a9af08514413d8cfdd8dcdc9373ebab1ba6eb525126db5ffff"
"sha256": "97a5bc5d29f162fe171ddbb8f4f5ba9fcba34ad336d4cdcae0c564223aa4cfe8"
}
],
"claims": [
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "./node_modules/knip/schema.json",
"entry": ["src/main.ts", "src/cli.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.mjs"],
"ignoreDependencies": [],
"ignoreDependencies": ["electron"],
"ignoreBinaries": ["ps"],
"ignoreExportsUsedInFile": true,
"ignoreIssues": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "rea-agents",
"version": "3.0.0",
"version": "3.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rea-agents",
"version": "3.0.0",
"version": "3.1.0",
"license": "MIT",
"dependencies": {
"@babel/parser": "8.0.4",
+22 -14
View File
@@ -1,6 +1,7 @@
{
"name": "rea-agents",
"version": "3.0.0",
"mcpName": "io.github.morluto/rea",
"version": "3.1.0",
"description": "Reverse engineer anything from your terminal or agent with one CLI and MCP server.",
"license": "MIT",
"repository": {
@@ -43,7 +44,8 @@
"bridge/hopper_bridge.py",
"bridge/ghidra/ReaGhidraBridge.java",
"scripts/rea.mjs",
"scripts/package-runner-bootstrap.mjs",
"scripts/electron-active-hook.cjs",
"scripts/electron-active-hook-boundaries.cjs",
"scripts/hopper-demo-x11.py",
"scripts/prepack.mjs",
"scripts/prepare.mjs",
@@ -55,9 +57,10 @@
"deps:check": "node scripts/check-dependency-install.mjs",
"metadata:generate": "node scripts/generate-package-metadata.mjs",
"metadata:check": "node scripts/generate-package-metadata.mjs --check",
"mcp-registry:check": "node scripts/verify-mcp-registry-metadata.mjs",
"mcp-catalog:generate": "npm run build:cached && node scripts/generate-mcp-tool-catalog.mjs",
"mcp-catalog:check": "node scripts/generate-mcp-tool-catalog.mjs --check",
"prebuild": "npm run deps:check && npm run metadata:check",
"prebuild": "npm run deps:check && npm run metadata:check && node scripts/clean-build-output.mjs",
"build": "tsc -p tsconfig.build.json",
"precheck": "npm run deps:check",
"check": "turbo run typecheck lint format:check knip metadata:check",
@@ -68,7 +71,7 @@
"precheck:test": "npm run deps:check",
"check:test": "turbo run typecheck lint format:check knip metadata:check test:run",
"precheck:pr": "npm run deps:check",
"check:pr": "turbo run typecheck lint format:check knip metadata:check test:run docs:check:built",
"check:pr": "turbo run typecheck lint format:check knip metadata:check test:run && npm run docs:check",
"precheck:ci": "npm run deps:check",
"check:ci": "turbo run typecheck lint format:check knip metadata:check jscpd scan:todos",
"build:cached": "turbo run build",
@@ -81,19 +84,22 @@
"jscpd": "jscpd src/ tests/",
"scan:todos": "node scripts/scan-todos.mjs",
"start": "node scripts/rea.mjs mcp",
"pretest": "npm run build:cached",
"test": "vitest run",
"test:run": "vitest run",
"test": "node scripts/run-exclusive.mjs test npm run test:with-build",
"test:with-build": "npm run build:cached && vitest run",
"test:run": "node scripts/run-exclusive.mjs test vitest run",
"test:local": "node scripts/run-exclusive.mjs test vitest run --changed --passWithNoTests --maxWorkers=1",
"pretest:fast": "npm run build:cached",
"test:fast": "vitest run --project domain --project services --project adapters --project composition --project boundary --project conformance",
"test:fast": "vitest run --project domain --project services --project adapters --project composition --project boundary --project mcp-boundary --project conformance",
"pretest:boundary": "npm run build:cached",
"test:boundary": "vitest run --project boundary --project process-global",
"test:boundary": "vitest run --project boundary --project mcp-boundary --project process-global",
"pretest:mcp": "npm run build:cached",
"test:mcp": "vitest run --project mcp-boundary",
"pretest:acceptance": "npm run build:cached",
"test:acceptance": "vitest run --project acceptance",
"pretest:changed": "npm run build:cached",
"test:changed": "vitest run --project domain --project services --project adapters --project composition --project boundary --project conformance --project evaluation --changed",
"test:changed": "vitest run --project domain --project services --project adapters --project composition --project boundary --project mcp-boundary --project conformance --project evaluation --changed",
"pretest:integration": "npm run build:cached",
"test:integration": "vitest run --project boundary --project process-global --project acceptance",
"test:integration": "vitest run --project boundary --project mcp-boundary --project process-global --project acceptance",
"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",
@@ -104,7 +110,7 @@
"pretest:verbose": "npm run build:cached",
"test:verbose": "vitest run --reporter=verbose",
"pretest:watch": "npm run build:cached",
"test:watch": "vitest --project domain --project services --project adapters --project composition --project boundary --project conformance --project evaluation --changed",
"test:watch": "vitest --project domain --project services --project adapters --project composition --project boundary --project mcp-boundary --project conformance --project evaluation --changed",
"pretest:watch:all": "npm run build:cached",
"test:watch:all": "vitest --changed",
"typecheck": "tsc --noEmit",
@@ -113,6 +119,7 @@
"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:electron": "npm run build:cached && node scripts/verify-real-electron.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",
@@ -134,8 +141,9 @@
"evidence:check": "npm run build:cached && node scripts/generate-completion-ledger.mjs --check",
"docs:api": "typedoc",
"docs:api:cached": "turbo run docs:api",
"docs:generate": "npm run metadata:generate && npm run build:cached && node scripts/generate-mcp-tool-catalog.mjs && node scripts/generate-skill-metadata.mjs && node scripts/generate-product-catalog.mjs && node scripts/generate-completion-ledger.mjs && npm run docs:api:cached && node scripts/generate-error-schema.mjs",
"docs:check": "turbo run docs:check:built",
"docs:generate": "node scripts/run-exclusive.mjs docs npm run docs:generate:unlocked",
"docs:generate:unlocked": "npm run metadata:generate && npm run build:cached && node scripts/generate-mcp-tool-catalog.mjs && node scripts/generate-skill-metadata.mjs && node scripts/generate-product-catalog.mjs && node scripts/generate-completion-ledger.mjs && npm run docs:api:cached && node scripts/generate-error-schema.mjs",
"docs:check": "node scripts/run-exclusive.mjs docs turbo run docs:check:built",
"docs:check:built": "node scripts/generate-package-metadata.mjs --check && node scripts/generate-mcp-tool-catalog.mjs --check && node scripts/generate-skill-metadata.mjs --check && node scripts/generate-product-catalog.mjs --check && node scripts/generate-completion-ledger.mjs --check && node scripts/check-doc-facts.mjs && node scripts/generate-error-schema.mjs --check",
"docs:error-schema": "npm run build:cached && node scripts/generate-error-schema.mjs",
"prepack": "node scripts/prepack.mjs",
+10
View File
@@ -25,6 +25,16 @@
"type": "generic",
"path": "src/generatedPackageMetadata.ts"
},
{
"type": "json",
"path": "server.json",
"jsonpath": "$.version"
},
{
"type": "json",
"path": "server.json",
"jsonpath": "$.packages[0].version"
},
{
"type": "json",
"path": "docs/product-catalog.json",
+10
View File
@@ -0,0 +1,10 @@
import { rm } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
await Promise.all([
rm(join(root, "dist"), { recursive: true, force: true }),
rm(join(root, ".cache", "tsconfig.build.tsbuildinfo"), { force: true }),
]);
+394
View File
@@ -0,0 +1,394 @@
"use strict";
// oxlint-disable-next-line max-lines-per-function -- all adapters must close over one bounded capture context.
const createElectronActiveBoundaryPatches = ({
identity,
patchEmitter,
processType,
recordIpc,
recordRuntime,
shape,
}) => {
const patchedSessions = new WeakSet();
const patchIpcRenderer = (ipcRenderer) => {
if (ipcRenderer === null || ipcRenderer === undefined) return;
for (const [method, kind] of [
["send", "ipc-renderer-send"],
["sendSync", "ipc-renderer-send"],
["invoke", "ipc-renderer-invoke"],
["postMessage", "ipc-renderer-post-message"],
]) {
const original = ipcRenderer[method];
if (typeof original !== "function") continue;
ipcRenderer[method] = function patchedRendererIpc(channel, ...args) {
const recorded = recordIpc(kind, channel, args, {
direction: "renderer-to-main",
sender: processType(),
receiver: "main",
frame: null,
process_type: processType(),
error: false,
});
try {
const result = original.call(this, channel, ...args);
if (result !== null && typeof result?.then === "function")
return result.then(
(value) => {
if (recorded !== null) recorded.result_shape = shape(value);
return value;
},
(cause) => {
if (recorded !== null) recorded.error = true;
throw cause;
},
);
if (recorded !== null) recorded.result_shape = shape(result);
return result;
} catch (cause) {
if (recorded !== null) recorded.error = true;
throw cause;
}
};
}
};
const patchContextBridge = (contextBridge) => {
if (contextBridge === null || contextBridge === undefined) return;
for (const method of ["exposeInMainWorld", "exposeInIsolatedWorld"]) {
const original = contextBridge[method];
if (typeof original !== "function") continue;
contextBridge[method] = function patchedContextBridge(...args) {
const target = typeof args[0] === "string" ? args[0] : null;
recordRuntime("preload", "context-bridge-exposure", "attempted", {
target,
argument_shapes: args.map((value) => shape(value)),
process_type: processType(),
});
try {
const result = original.apply(this, args);
recordRuntime("preload", "context-bridge-exposure", "completed", {
target,
process_type: processType(),
});
return result;
} catch (cause) {
recordRuntime("preload", "context-bridge-exposure", "failed", {
target,
error: true,
process_type: processType(),
});
throw cause;
}
};
}
};
const patchSession = (session) => {
if (session === null || session === undefined) return;
if (
(typeof session !== "object" && typeof session !== "function") ||
patchedSessions.has(session)
)
return;
patchedSessions.add(session);
patchEmitter(session, (event, args) => {
const kind =
event === "will-download"
? "download"
: event.includes("certificate") || event.includes("permission")
? "permission"
: event.includes("window") || event.includes("popup")
? "popup-attempt"
: "web-contents-lifecycle";
if (event === "will-download") {
const cancel = args[1]?.cancel;
if (typeof cancel === "function") cancel.call(args[1]);
}
recordRuntime(
kind,
event,
event === "will-download" ? "blocked" : "observed",
{
target: identity(args[0], "webContents"),
argument_shapes: args.map((value) => shape(value)),
error: event === "will-download",
},
);
});
const originalPermissionRequest = session.setPermissionRequestHandler;
if (typeof originalPermissionRequest === "function")
session.setPermissionRequestHandler =
function blockedPermissionRequest() {
return originalPermissionRequest.call(
this,
(webContents, permission, callback) => {
recordRuntime("permission", "permission-request", "blocked", {
target: identity(webContents, "webContents"),
argument_shapes: [shape(permission)],
error: true,
});
if (typeof callback === "function") callback(false);
},
);
};
const originalPermissionCheck = session.setPermissionCheckHandler;
if (typeof originalPermissionCheck === "function")
session.setPermissionCheckHandler = function blockedPermissionCheck() {
return originalPermissionCheck.call(this, (webContents, permission) => {
recordRuntime("permission", "permission-check", "blocked", {
target: identity(webContents, "webContents"),
argument_shapes: [shape(permission)],
error: true,
});
return false;
});
};
const originalCertificate = session.setCertificateVerifyProc;
if (typeof originalCertificate === "function")
session.setCertificateVerifyProc = function blockedCertificate() {
return originalCertificate.call(this, (request, callback) => {
recordRuntime("permission", "certificate-error", "blocked", {
argument_shapes: [shape(request)],
error: true,
});
if (typeof callback === "function") callback(-2);
});
};
};
const patchWindowOpenHandler = (webContents, contentsId) => {
if (webContents === null || webContents === undefined) return;
const original = webContents.setWindowOpenHandler;
if (typeof original !== "function") return;
const createGuardedHandler =
(handler) =>
(...args) => {
try {
if (typeof handler === "function") handler(...args);
} catch {
recordRuntime("popup-attempt", "window-open-handler", "failed", {
target: contentsId,
argument_shapes: args.map((value) => shape(value)),
error: true,
});
return { action: "deny" };
}
recordRuntime("popup-attempt", "window-open-handler", "blocked", {
target: contentsId,
argument_shapes: args.map((value) => shape(value)),
error: true,
});
return { action: "deny" };
};
try {
original.call(webContents, createGuardedHandler(undefined));
} catch {
recordRuntime("popup-attempt", "window-open-handler-install", "failed", {
target: contentsId,
error: true,
});
}
webContents.setWindowOpenHandler = function blockedWindowOpenHandler(
handler,
) {
return original.call(this, createGuardedHandler(handler));
};
};
const patchSessionManager = (sessionModule) => {
if (sessionModule === null || sessionModule === undefined) return;
patchSession(sessionModule.defaultSession);
for (const method of ["fromPartition", "fromPath"]) {
const original = sessionModule[method];
if (typeof original !== "function") continue;
sessionModule[method] = function patchedSessionFactory(...args) {
const session = original.apply(this, args);
patchSession(session);
return session;
};
}
};
const patchApplicationEffects = (app) => {
if (app === null || app === undefined) return;
for (const method of [
"relaunch",
"setAsDefaultProtocolClient",
"setLoginItemSettings",
]) {
const original = app[method];
if (typeof original !== "function") continue;
app[method] = function blockedApplicationEffect(...args) {
recordRuntime(
method === "relaunch" ? "updater" : "protocol",
method,
"blocked",
{ argument_shapes: args.map((value) => shape(value)), error: true },
);
return method === "setAsDefaultProtocolClient" ? false : undefined;
};
}
};
const patchNativeAddonLoading = () => {
const original = process.dlopen;
if (typeof original !== "function") return;
process.dlopen = function patchedDlopen(module, filename, ...args) {
let digest = null;
try {
const fs = require("node:fs");
const crypto = require("node:crypto");
const stat = fs.statSync(filename);
if (stat.isFile() && stat.size <= 16 * 1024 * 1024)
digest = crypto
.createHash("sha256")
.update(fs.readFileSync(filename))
.digest("hex");
} catch {
digest = null;
}
recordRuntime("native-addon", "dlopen", "attempted", {
artifact_path: typeof filename === "string" ? filename : null,
artifact_sha256: digest,
argument_shapes: [
shape(module),
shape(filename),
...args.map((value) => shape(value)),
],
});
try {
const result = original.call(this, module, filename, ...args);
recordRuntime("native-addon", "dlopen", "completed", {
artifact_path: typeof filename === "string" ? filename : null,
artifact_sha256: digest,
});
return result;
} catch (cause) {
recordRuntime("native-addon", "dlopen", "failed", {
artifact_path: typeof filename === "string" ? filename : null,
artifact_sha256: digest,
error: true,
});
throw cause;
}
};
};
const patchUtilityProcess = (utilityProcess) => {
if (utilityProcess === null || utilityProcess === undefined) return;
const original = utilityProcess.fork;
if (typeof original !== "function") return;
utilityProcess.fork = function patchedFork(...args) {
recordRuntime("process-lifecycle", "utility-process-fork", "attempted", {
argument_shapes: args.map((value) => shape(value)),
process_type: "main",
});
const child = original.apply(this, args);
const childId = identity(child, "utility");
recordRuntime("process-lifecycle", "utility-process-fork", "completed", {
target: childId,
process_type: "main",
});
patchEmitter(child, (event, eventArgs) => {
if (event === "message") {
recordIpc("ipc-utility-to-main", null, eventArgs, {
direction: "utility-to-main",
sender: childId,
receiver: "main",
target: childId,
process_type: "main",
error: false,
});
return;
}
recordRuntime("process-lifecycle", `utility.${event}`, "observed", {
target: childId,
process_type: "utility",
argument_shapes: eventArgs.map((value) => shape(value)),
});
});
if (child && typeof child.postMessage === "function") {
const postMessage = child.postMessage.bind(child);
child.postMessage = (...messageArgs) => {
const recorded = recordIpc(
"utility-process-message",
null,
messageArgs,
{
direction: "main-to-utility",
sender: "main",
receiver: childId,
target: childId,
process_type: "main",
error: false,
},
);
try {
const result = postMessage(...messageArgs);
if (recorded !== null) recorded.result_shape = shape(result);
return result;
} catch (cause) {
if (recorded !== null) recorded.error = true;
throw cause;
}
};
}
return child;
};
};
const patchShell = (shell) => {
if (shell === null || shell === undefined) return;
for (const method of ["openExternal", "openPath"]) {
const original = shell[method];
if (typeof original !== "function") continue;
shell[method] = function blockedShellCall(...args) {
recordRuntime("shell-attempt", method, "blocked", {
argument_shapes: args.map((value) => shape(value)),
error: true,
});
return Promise.reject(new Error("External shell effects are blocked"));
};
}
};
const patchChildProcess = () => {
const childProcess = require("node:child_process");
for (const method of ["spawn", "fork", "exec", "execFile"]) {
const original = childProcess[method];
if (typeof original !== "function") continue;
childProcess[method] = function patchedChildProcess(...args) {
recordRuntime("process-lifecycle", `child.${method}`, "attempted", {
argument_shapes: args.map((value) => shape(value)),
});
const child = original.apply(this, args);
const childId = identity(child, "child");
recordRuntime("process-lifecycle", `child.${method}`, "completed", {
target: childId,
});
patchEmitter(child, (event, eventArgs) =>
recordRuntime("process-lifecycle", `child.${event}`, "observed", {
target: childId,
argument_shapes: eventArgs.map((value) => shape(value)),
}),
);
return child;
};
}
};
return {
patchApplicationEffects,
patchChildProcess,
patchContextBridge,
patchIpcRenderer,
patchNativeAddonLoading,
patchSession,
patchSessionManager,
patchShell,
patchWindowOpenHandler,
patchUtilityProcess,
};
};
module.exports = { createElectronActiveBoundaryPatches };
+501
View File
@@ -0,0 +1,501 @@
"use strict";
const {
createElectronActiveBoundaryPatches,
} = require("./electron-active-hook-boundaries.cjs");
const boundedEnvironmentNumber = (name, fallback) => {
const value = Number(process.env[name]);
return Number.isInteger(value)
? Math.min(20_000, Math.max(1, value))
: fallback;
};
const MAX_EVENT_NAME_LENGTH = 128;
const MAX_CHANNEL_LENGTH = 1_024;
const MAX_IDENTIFIER_LENGTH = 256;
const MAX_ARGUMENT_SHAPES = 32;
const maxEvents = boundedEnvironmentNumber(
"REA_ELECTRON_ACTIVE_MAX_RUNTIME_EVENTS",
5_000,
);
const ipcEventKinds = new Set([
"main-handler-invocation",
"main-event-invocation",
"utility-process-fork",
"utility-process-message",
"ipc-main-to-renderer",
"ipc-utility-to-main",
"ipc-renderer-send",
"ipc-renderer-invoke",
"ipc-renderer-post-message",
]);
const events = [];
let observed = 0;
let observedIpc = 0;
let observedRuntime = 0;
let truncated = false;
let sequence = 0;
let correlationSequence = 0;
const boundedString = (value, maximum) =>
typeof value === "string" ? value.slice(0, maximum) : null;
const shape = (value, depth = 0) => {
if (depth > 2 || value === null) return value === null ? "null" : "nested";
if (Array.isArray(value)) return "array";
switch (typeof value) {
case "string":
return "string";
case "number":
return "number";
case "boolean":
return "boolean";
case "undefined":
return "undefined";
case "function":
return "function";
case "object":
return "object";
default:
return "unknown";
}
};
const isAllowedNavigation = (value) => {
if (typeof value !== "string") return true;
try {
const parsed = new URL(value);
if (["file:", "data:", "about:"].includes(parsed.protocol)) return true;
return (
["http:", "https:"].includes(parsed.protocol) &&
["127.0.0.1", "localhost", "[::1]", "::1"].includes(parsed.hostname)
);
} catch {
return false;
}
};
const processType = () =>
typeof process.type === "string" && process.type.length > 0
? process.type.slice(0, MAX_IDENTIFIER_LENGTH)
: "main";
const identity = (value, prefix) => {
if (value === null || value === undefined) return null;
const id = value.id;
const processId = value.pid;
return typeof id === "string" || typeof id === "number"
? `${prefix}:${String(id)}`.slice(0, MAX_IDENTIFIER_LENGTH)
: typeof processId === "number"
? `${prefix}:pid:${String(processId)}`.slice(0, MAX_IDENTIFIER_LENGTH)
: null;
};
const frameIdentity = (event) => {
const frame = event?.senderFrame ?? event?.frame;
const id = frame?.frameTreeNodeId ?? frame?.routingId ?? frame?.id;
return typeof id === "string" || typeof id === "number"
? `frame:${String(id)}`.slice(0, MAX_IDENTIFIER_LENGTH)
: null;
};
const record = (event) => {
observed += 1;
if (ipcEventKinds.has(event.kind)) observedIpc += 1;
else observedRuntime += 1;
if (events.length >= maxEvents) {
truncated = true;
return null;
}
const raw = {
sequence: ++sequence,
correlation_id:
typeof event.correlation_id === "string"
? event.correlation_id
: `capture:${process.pid}:${++correlationSequence}`,
event: null,
phase: "observed",
channel: null,
channel_truncated: false,
direction: null,
sender: null,
receiver: null,
frame: null,
target: null,
argument_shapes: [],
argument_shapes_truncated: false,
result_shape: null,
process_type: processType(),
source: "electron-active-hook",
capture_method: "api-wrapper",
artifact_path: null,
artifact_sha256: null,
error: false,
...event,
};
const argumentShapes = Array.isArray(raw.argument_shapes)
? raw.argument_shapes
: [];
events.push({
...raw,
correlation_id: boundedString(raw.correlation_id, MAX_IDENTIFIER_LENGTH),
event: boundedString(raw.event, MAX_EVENT_NAME_LENGTH),
channel: boundedString(raw.channel, MAX_CHANNEL_LENGTH),
channel_truncated:
raw.channel_truncated === true ||
(typeof raw.channel === "string" &&
raw.channel.length > MAX_CHANNEL_LENGTH),
sender: boundedString(raw.sender, MAX_IDENTIFIER_LENGTH),
receiver: boundedString(raw.receiver, MAX_IDENTIFIER_LENGTH),
frame: boundedString(raw.frame, MAX_IDENTIFIER_LENGTH),
target: boundedString(raw.target, MAX_IDENTIFIER_LENGTH),
argument_shapes: argumentShapes
.slice(0, MAX_ARGUMENT_SHAPES)
.map((value) => boundedString(value, 64) ?? "unknown"),
argument_shapes_truncated:
raw.argument_shapes_truncated === true ||
argumentShapes.length > MAX_ARGUMENT_SHAPES,
result_shape: boundedString(raw.result_shape, 64),
process_type: boundedString(raw.process_type, MAX_IDENTIFIER_LENGTH),
artifact_path: boundedString(raw.artifact_path, 16_384),
artifact_sha256: boundedString(raw.artifact_sha256, 64),
});
return events.at(-1);
};
const recordRuntime = (kind, event, phase, details = {}) =>
record({ kind, event, phase, ...details });
const recordIpc = (kind, channel, args, details = {}) =>
(() => {
const values = Array.isArray(args) ? args : [];
return record({
kind,
channel: typeof channel === "string" ? channel : null,
argument_shapes: values
.slice(0, MAX_ARGUMENT_SHAPES)
.map((value) => shape(value)),
argument_shapes_truncated: values.length > MAX_ARGUMENT_SHAPES,
...details,
});
})();
const patchEmitter = (emitter, observeEvent) => {
if (emitter === null || emitter === undefined) return;
const original = emitter.emit;
if (typeof original !== "function") return;
if (emitter.__reaElectronActiveEmitPatched === true) return;
try {
emitter.__reaElectronActiveEmitPatched = true;
emitter.emit = function patchedEmit(event, ...args) {
if (typeof event === "string") observeEvent.call(this, event, args);
return original.call(this, event, ...args);
};
} catch {
globalThis.__reaElectronActiveHookError = true;
}
};
const recordInvocation = (kind, channel, event, args) =>
recordIpc(kind, channel, args, {
direction: "renderer-to-main",
sender: identity(event?.sender, "webContents"),
receiver: "main",
frame: frameIdentity(event),
process_type: "main",
error: false,
});
const patchIpcMain = (ipcMain) => {
if (ipcMain === null || ipcMain === undefined) return;
for (const [method, kind] of [
["handle", "main-handler-invocation"],
["on", "main-event-invocation"],
["once", "main-event-invocation"],
]) {
const original = ipcMain[method];
if (typeof original !== "function") continue;
ipcMain[method] = function patchedIpcMain(channel, listener) {
if (typeof listener !== "function")
return original.call(this, channel, listener);
const wrapped =
kind === "main-handler-invocation"
? async function wrappedHandler(event, ...args) {
const recorded = recordInvocation(kind, channel, event, args);
try {
const result = await listener.call(this, event, ...args);
if (recorded !== null) recorded.result_shape = shape(result);
return result;
} catch (cause) {
if (recorded !== null) recorded.error = true;
throw cause;
}
}
: function wrappedEventListener(event, ...args) {
const recorded = recordInvocation(kind, channel, event, args);
try {
return listener.call(this, event, ...args);
} catch (cause) {
if (recorded !== null) recorded.error = true;
throw cause;
}
};
return original.call(this, channel, wrapped);
};
}
};
const patchNavigationMethods = (webContents, contentsId) => {
for (const method of ["loadURL", "loadFile", "loadDataURL"]) {
const original = webContents[method];
if (typeof original !== "function") continue;
webContents[method] = function patchedNavigation(...args) {
recordRuntime("navigation", method, "attempted", {
target: contentsId,
argument_shapes: args.map((value) => shape(value)),
});
if (method === "loadURL" && !isAllowedNavigation(args[0])) {
recordRuntime("navigation", method, "blocked", {
target: contentsId,
error: true,
});
return Promise.reject(new Error("External navigation is blocked"));
}
try {
const result = original.apply(this, args);
if (result !== null && typeof result?.then === "function")
return result.then(
(value) => {
recordRuntime("navigation", method, "completed", {
target: contentsId,
});
return value;
},
(cause) => {
recordRuntime("navigation", method, "failed", {
target: contentsId,
error: true,
});
throw cause;
},
);
recordRuntime("navigation", method, "completed", {
target: contentsId,
});
return result;
} catch (cause) {
recordRuntime("navigation", method, "failed", {
target: contentsId,
error: true,
});
throw cause;
}
};
}
};
const patchWebContentsIpc = (webContents, contentsId) => {
const originalSend = webContents.send;
if (typeof originalSend === "function")
webContents.send = function patchedSend(channel, ...args) {
const recorded = recordIpc("ipc-main-to-renderer", channel, args, {
direction: "main-to-renderer",
sender: "main",
receiver: contentsId,
target: contentsId,
process_type: "main",
error: false,
});
try {
const result = originalSend.call(this, channel, ...args);
if (recorded !== null) recorded.result_shape = shape(result);
return result;
} catch (cause) {
if (recorded !== null) recorded.error = true;
throw cause;
}
};
const originalPostMessage = webContents.postMessage;
if (typeof originalPostMessage === "function")
webContents.postMessage = function patchedPostMessage(
channel,
message,
transfer,
) {
const recorded = recordIpc(
"ipc-main-to-renderer",
channel,
[message, transfer],
{
direction: "main-to-renderer",
sender: "main",
receiver: contentsId,
target: contentsId,
process_type: "main",
error: false,
},
);
try {
const result = originalPostMessage.call(
this,
channel,
message,
transfer,
);
if (recorded !== null) recorded.result_shape = shape(result);
return result;
} catch (cause) {
if (recorded !== null) recorded.error = true;
throw cause;
}
};
};
const patchWebContents = (webContents, windowId) => {
if (webContents === null || webContents === undefined) return;
const contentsId = identity(webContents, "webContents");
patchEmitter(webContents, (event, args) => {
const navigation = [
"did-start-loading",
"did-stop-loading",
"did-finish-load",
"did-fail-load",
"did-frame-finish-load",
"will-navigate",
"will-redirect",
].includes(event);
const kind = navigation
? "navigation"
: [
"console-message",
"render-process-gone",
"crashed",
"unresponsive",
"responsive",
].includes(event)
? "error"
: event === "did-create-window"
? "popup-attempt"
: "web-contents-lifecycle";
if (event === "will-navigate" || event === "will-redirect") {
if (!isAllowedNavigation(args[1])) {
const preventDefault = args[0]?.preventDefault;
if (typeof preventDefault === "function") preventDefault.call(args[0]);
recordRuntime("navigation", event, "blocked", {
target: contentsId,
sender: windowId,
argument_shapes: args.map((value) => shape(value)),
error: true,
});
return;
}
}
recordRuntime(kind, event, "observed", {
target: contentsId,
sender: windowId,
argument_shapes: args.map((value) => shape(value)),
error: kind === "error",
});
if (event === "did-create-window") {
const childWindow = args[0];
patchWebContents(
childWindow?.webContents,
identity(childWindow, "window"),
);
}
});
patchNavigationMethods(webContents, contentsId);
patchWebContentsIpc(webContents, contentsId);
boundaries.patchWindowOpenHandler(webContents, contentsId);
};
const patchBrowserWindow = (electron) => {
const original = electron.BrowserWindow;
if (typeof original !== "function") return;
electron.BrowserWindow = new Proxy(original, {
construct(target, args, newTarget) {
const window = Reflect.construct(target, args, newTarget);
const targetId = identity(window, "window");
recordRuntime("window-lifecycle", "created", "completed", {
target: targetId,
argument_shapes: args.map((value) => shape(value)),
});
const preload = args[0]?.webPreferences?.preload;
if (typeof preload === "string")
recordRuntime("preload", "configured", "completed", {
target: targetId,
artifact_path: preload,
});
patchEmitter(window, (event) =>
recordRuntime("window-lifecycle", event, "observed", {
target: targetId,
}),
);
patchWebContents(window.webContents, targetId);
return window;
},
});
};
const boundaries = createElectronActiveBoundaryPatches({
identity,
patchEmitter,
processType,
recordIpc,
recordRuntime,
shape,
});
try {
const electron = require("electron");
patchEmitter(electron.app, (event, args) =>
recordRuntime(
event === "open-url" || event === "second-instance"
? "protocol"
: "app-lifecycle",
event,
"observed",
{
target: "app",
argument_shapes: args.map((value) => shape(value)),
process_type: "main",
},
),
);
patchIpcMain(electron.ipcMain);
boundaries.patchIpcRenderer(electron.ipcRenderer);
boundaries.patchContextBridge(electron.contextBridge);
patchBrowserWindow(electron);
boundaries.patchUtilityProcess(electron.utilityProcess);
boundaries.patchShell(electron.shell);
boundaries.patchSessionManager(electron.session);
boundaries.patchApplicationEffects(electron.app);
boundaries.patchNativeAddonLoading();
boundaries.patchChildProcess();
process.once("uncaughtException", (cause, origin) => {
recordRuntime("process-lifecycle", "uncaught-exception", "observed", {
argument_shapes: [shape(cause), shape(origin)],
error: true,
});
throw cause;
});
process.once("unhandledRejection", (reason, promise) => {
recordRuntime("process-lifecycle", "unhandled-rejection", "observed", {
argument_shapes: [shape(reason), shape(promise)],
error: true,
});
throw reason;
});
} catch {
globalThis.__reaElectronActiveHookError = true;
}
globalThis.__reaElectronActiveSnapshot = () => ({
events: events.slice(),
observed,
observed_ipc: observedIpc,
observed_runtime: observedRuntime,
truncated,
hook_error: globalThis.__reaElectronActiveHookError === true,
});
+15 -4
View File
@@ -30,16 +30,27 @@ const { stdout } = await exec(
},
);
const verifierOutput = JSON.parse(stdout);
// Managed conformance claims are host-independent. Keep the checked-in
// commitment reproducible across macOS, Linux, and Windows rather than
// embedding the machine that happened to generate it.
const portableCompletionReport = {
...verifierOutput.completionReport,
environment: {
platform: "portable",
architecture: "portable",
runtime: "node",
runtime_version: "portable",
},
};
const { createCompletionLedgerArtifacts } = await import(
`../dist/domain/completionLedgerGeneration.js?${String(Date.now())}`
);
const skillDigest = await directoryDigest(
join(root, "skills/reverse-engineer-anything"),
);
const generated = createCompletionLedgerArtifacts(
verifierOutput.completionReport,
[{ skill_id: "reverse-engineer-anything", sha256: skillDigest }],
);
const generated = createCompletionLedgerArtifacts(portableCompletionReport, [
{ skill_id: "reverse-engineer-anything", sha256: skillDigest },
]);
if (!check) await mkdir(outputRoot, { recursive: true });
await Promise.all([
+53 -12
View File
@@ -1,6 +1,8 @@
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { format } from "prettier";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { McpServer } from "@modelcontextprotocol/server";
import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js";
import { MANAGED_WORKFLOW_TOOL_CONTRACTS } from "../dist/contracts/managedWorkflowToolContracts.js";
@@ -16,14 +18,20 @@ for (const argument of arguments_)
throw new Error(`Unknown MCP tool catalog option: ${argument}`);
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
// The checked-in catalog is a portable package artifact. Keep its canonical
// provider metadata independent of the machine running the generator.
const CATALOG_PLATFORM = "darwin";
const sessionToolNames = new Set([
...TOOL_CONTRACTS.filter(({ kind }) => kind === "session").map(
({ name }) => name,
),
...MANAGED_WORKFLOW_TOOL_CONTRACTS.map(({ name }) => name),
]);
const advertisedTools = await sdkToolCatalog();
const catalog = TOOL_CONTRACTS.map((contract) => {
const options = toolRegistrationOptions(contract);
const advertised = advertisedTools.get(contract.name);
if (advertised === undefined)
throw new Error(`MCP SDK omitted registered tool ${contract.name}`);
return {
name: contract.name,
analysisOperation: [
@@ -35,29 +43,62 @@ const catalog = TOOL_CONTRACTS.map((contract) => {
].includes(contract.kind)
? contract.name
: null,
title: options.title,
description: options.description,
title: advertised.title,
description: advertised.description,
kind: contract.kind,
requiresSession: sessionToolNames.has(contract.name),
inputSchema: options.inputSchema["~standard"].jsonSchema.input({
target: "draft-2020-12",
}),
outputSchema: options.outputSchema["~standard"].jsonSchema.output({
target: "draft-2020-12",
}),
annotations: options.annotations,
inputSchema: advertised.inputSchema,
outputSchema: advertised.outputSchema,
annotations: advertised.annotations,
effects: contract.effects,
};
});
async function sdkToolCatalog() {
const server = new McpServer({ name: "rea-catalog-generator", version: "0" });
for (const contract of TOOL_CONTRACTS)
server.registerTool(
contract.name,
toolRegistrationOptions(contract),
async () => ({
content: [{ type: "text", text: "Catalog-only handler" }],
isError: true,
}),
);
const client = new Client({ name: "rea-catalog-generator", version: "0" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const { tools } = await client.listTools();
return new Map(tools.map((tool) => [tool.name, tool]));
} finally {
await Promise.allSettled([client.close(), server.close()]);
}
}
const auxiliaryProviders = [
new ArtifactProvider(false, false),
new NativeMacOSProvider(),
new NativeMacOSProvider(undefined, CATALOG_PLATFORM),
new ManagedStaticProvider(),
].map((provider) => ({
identity: provider.identity(),
capabilities: provider.capabilities(),
}));
const payloadJson = JSON.stringify({ catalog, auxiliaryProviders });
const canonicalizeJson = (value) => {
if (Array.isArray(value)) return value.map(canonicalizeJson);
if (value === null || typeof value !== "object") return value;
return Object.fromEntries(
Object.entries(value)
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([key, entry]) => [key, canonicalizeJson(entry)]),
);
};
const payloadJson = JSON.stringify(
canonicalizeJson({ catalog, auxiliaryProviders }),
);
const source = await format(
`import type { ToolAnnotations } from "@modelcontextprotocol/server";
import type {
+47 -20
View File
@@ -81,26 +81,12 @@ export const toolFamilyCatalog = (sources) => {
/** Build provider identities with their sorted capability names. */
export const providerCatalog = (sources) => {
const applicationContracts =
sources.electronContracts.ELECTRON_TOOL_CONTRACTS.filter(
({ name }) => name === "analyze_javascript_application",
);
const reconciliationContracts =
sources.electronContracts.ELECTRON_TOOL_CONTRACTS.filter(
({ name }) => name === "reconcile_javascript_runtime",
);
const observationContracts =
sources.electronContracts.ELECTRON_TOOL_CONTRACTS.filter(
({ name }) =>
name !== "analyze_javascript_application" &&
name !== "reconcile_javascript_runtime",
);
if (
applicationContracts.length !== 1 ||
reconciliationContracts.length !== 1 ||
observationContracts.length !== 2
)
throw new Error("Electron provider capability ownership drifted");
const {
applicationContracts,
reconciliationContracts,
observationContracts,
activeContracts,
} = electronContractSlices(sources);
return [
{
identity: sources.hopperProvider.HOPPER_PROVIDER_IDENTITY,
@@ -142,6 +128,12 @@ export const providerCatalog = (sources) => {
identity: sources.electronProvider.CDP_ELECTRON_PROVIDER_IDENTITY,
contracts: observationContracts,
},
{
identity:
sources.electronActiveProvider
.PLAYWRIGHT_ELECTRON_ACTIVE_PROVIDER_IDENTITY,
contracts: activeContracts,
},
{
identity: sources.v8InspectorProvider.V8_INSPECTOR_PROVIDER_IDENTITY,
contracts:
@@ -171,3 +163,38 @@ export const providerCatalog = (sources) => {
}))
.sort((left, right) => left.id.localeCompare(right.id));
};
const electronContractSlices = (sources) => {
const applicationContracts =
sources.electronContracts.ELECTRON_TOOL_CONTRACTS.filter(
({ name }) => name === "analyze_javascript_application",
);
const reconciliationContracts =
sources.electronContracts.ELECTRON_TOOL_CONTRACTS.filter(
({ name }) => name === "reconcile_javascript_runtime",
);
const observationContracts =
sources.electronContracts.ELECTRON_TOOL_CONTRACTS.filter(
({ name }) =>
name !== "analyze_javascript_application" &&
name !== "reconcile_javascript_runtime" &&
name !== "capture_electron_scenario",
);
const activeContracts =
sources.electronContracts.ELECTRON_TOOL_CONTRACTS.filter(
({ name }) => name === "capture_electron_scenario",
);
if (
applicationContracts.length !== 1 ||
reconciliationContracts.length !== 1 ||
observationContracts.length !== 2 ||
activeContracts.length !== 1
)
throw new Error("Electron provider capability ownership drifted");
return {
applicationContracts,
reconciliationContracts,
observationContracts,
activeContracts,
};
};
+2
View File
@@ -55,6 +55,7 @@ const SOURCE_PATHS = {
browserProvider: "dist/browser/CdpBrowserProvider.js",
browserScenarioProvider: "dist/browser/PlaywrightBrowserScenarioProvider.js",
electronProvider: "dist/browser/CdpElectronProvider.js",
electronActiveProvider: "dist/browser/PlaywrightElectronActiveProvider.js",
v8InspectorProvider: "dist/browser/V8InspectorProvider.js",
evidence: "dist/domain/evidence.js",
evidenceBundle: "dist/domain/evidenceBundle.js",
@@ -70,6 +71,7 @@ const SOURCE_PATHS = {
browserScenarioDiff: "dist/domain/browserScenarioDiff.js",
browserSession: "dist/domain/browserSession.js",
electronObservation: "dist/domain/electronObservation.js",
electronActiveObservation: "dist/domain/electronActiveObservation.js",
javascriptRuntimeObservation: "dist/domain/javascriptRuntimeObservation.js",
webBundleAnalysis: "dist/domain/webBundleAnalysis.js",
webCaptureDiff: "dist/domain/webCaptureDiff.js",
+4 -7
View File
@@ -28,8 +28,8 @@ 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) => {
/** Verify that the complete advertised MCP catalog matches session metadata. */
export const verifyCompleteToolCatalog = async (client, options) => {
const listed = await client.listTools(undefined, options);
const status = await client.callTool(
{ name: "binary_session", arguments: { detail: "full" } },
@@ -38,14 +38,11 @@ export const verifyAvailableToolCatalog = async (client, 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 expected = availability.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",
"packaged MCP tool inventory diverged from session metadata",
);
return observed;
};
-19
View File
@@ -1,19 +0,0 @@
export interface PackageRunnerSetupInput {
readonly args: readonly string[];
readonly environment: Readonly<Record<string, string | undefined>>;
readonly packageRoot: string;
readonly packageName: string;
}
export interface PackageRunnerSetupPlan {
readonly command: "npm";
readonly args: readonly string[];
}
export function packageRunnerSetupPlan(
input: PackageRunnerSetupInput,
): PackageRunnerSetupPlan | undefined;
export function runPackageRunnerSetupBootstrap(
input: PackageRunnerSetupInput,
): Promise<number | undefined>;
-72
View File
@@ -1,72 +0,0 @@
import { spawn } from "node:child_process";
import { resolve } from "node:path";
const BOOTSTRAP_MARKER = "REA_PACKAGE_RUNNER_BOOTSTRAPPED";
/**
* Plan a current-release setup bootstrap when bare npx selected a project-local
* package. Explicit `npm exec --package=<name>@<version>` rollbacks bypass it.
*/
export function packageRunnerSetupPlan(input) {
if (input.args[0] !== "setup") return undefined;
if (input.environment[BOOTSTRAP_MARKER] === "1") return undefined;
if (input.environment.npm_lifecycle_event !== "npx") return undefined;
const localPrefix = input.environment.npm_config_local_prefix;
if (localPrefix === undefined || localPrefix.length === 0) return undefined;
if (
!isPackageBelowLocalPrefix(
input.packageRoot,
localPrefix,
input.packageName,
)
)
return undefined;
const requestedPackage = input.environment.npm_config_package;
if (
requestedPackage !== undefined &&
isExactPackageVersion(requestedPackage, input.packageName)
)
return undefined;
return {
command: "npm",
args: [
"exec",
"--yes",
`--package=${input.packageName}@latest`,
"--",
"rea",
...input.args,
],
};
}
/** Execute a planned package-runner bootstrap and return its exit status. */
export async function runPackageRunnerSetupBootstrap(input) {
const plan = packageRunnerSetupPlan(input);
if (plan === undefined) return undefined;
return await new Promise((resolveExitCode) => {
const child = spawn(plan.command, plan.args, {
stdio: "inherit",
env: { ...input.environment, [BOOTSTRAP_MARKER]: "1" },
});
child.once("error", () => resolveExitCode(1));
child.once("exit", (code) => resolveExitCode(code ?? 1));
});
}
function isPackageBelowLocalPrefix(packageRoot, localPrefix, packageName) {
const expectedRoot = resolve(localPrefix, "node_modules", packageName);
return resolve(packageRoot) === expectedRoot;
}
function isExactPackageVersion(packageSpecifier, packageName) {
const version = packageSpecifier.slice(`${packageName}@`.length);
return (
packageSpecifier.startsWith(`${packageName}@`) &&
/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test(version)
);
}
+1 -11
View File
@@ -4,8 +4,6 @@ import { access } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { runPackageRunnerSetupBootstrap } from "./package-runner-bootstrap.mjs";
// Route production MCP before importing Incur. Incur owns registration helpers
// such as `mcp add`, while only dist/main.js may serve the stdio tool catalog.
const args = process.argv.slice(2);
@@ -17,21 +15,13 @@ const isMcpMode =
args.length === 1 && (args[0] === "--mcp" || args[0] === "mcp");
const isMcpDoctorMode = args[0] === "mcp" && args[1] === "doctor";
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const bootstrapExitCode = await runPackageRunnerSetupBootstrap({
args,
environment: process.env,
packageRoot,
packageName: packageJson.name,
});
const runtimeFiles = isMcpMode
? ["dist/main.js"]
: isMcpDoctorMode
? ["dist/main.js", "dist/mcpDoctor.js"]
: ["dist/cli.js", "dist/cliOutput.js"];
if (bootstrapExitCode !== undefined) {
process.exitCode = bootstrapExitCode;
} else if (!(await compiledRuntimeExists(runtimeFiles))) {
if (!(await compiledRuntimeExists(runtimeFiles))) {
process.stderr.write(
`REA's compiled runtime is missing. Run \`npm ci\` in ${packageRoot} to install dependencies and build REA, then restart it. If this is an installed package, reinstall rea-agents.\n`,
);
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
const [, , lockName, ...command] = process.argv;
const lockRoot = resolve(process.cwd(), ".cache", "rea-command-locks");
const lockPath = join(lockRoot, `${lockName ?? "invalid"}.lock`);
const ownerPath = join(lockPath, "owner.json");
const signalExitCodes = { SIGINT: 130, SIGTERM: 143 };
let lockHeld = false;
let child;
let shutdownSignal;
process.on("SIGINT", () => handleSignal("SIGINT"));
process.on("SIGTERM", () => handleSignal("SIGTERM"));
if (
typeof lockName !== "string" ||
!/^[a-z][a-z0-9-]*$/u.test(lockName) ||
command.length === 0
) {
console.error("Usage: run-exclusive.mjs <lock-name> <command> [args...]");
process.exitCode = 64;
} else {
await run();
}
async function run() {
try {
await acquireLock();
const result = await runCommand();
process.exitCode =
shutdownSignal === undefined
? (result.code ?? 1)
: signalExitCodes[shutdownSignal];
} catch (cause) {
console.error(cause instanceof Error ? cause.message : String(cause));
process.exitCode = 1;
} finally {
await releaseLock();
}
}
async function acquireLock() {
await mkdir(lockRoot, { recursive: true });
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
await mkdir(lockPath);
await writeFile(
ownerPath,
JSON.stringify({
pid: process.pid,
command: command.join(" "),
started_at: new Date().toISOString(),
}),
"utf8",
);
lockHeld = true;
return;
} catch (cause) {
if (!isAlreadyExistsError(cause)) throw cause;
const owner = await readOwner();
if (owner === null || isProcessAlive(owner.pid))
throw new Error(
`Another ${lockName} command is already running (pid ${String(owner?.pid ?? "unknown")}). Wait for it to finish or inspect ${lockPath}.`,
);
await rm(lockPath, { force: true, recursive: true });
}
}
throw new Error(`Could not acquire the ${lockName} command lock`);
}
async function releaseLock() {
if (!lockHeld) return;
lockHeld = false;
try {
const owner = await readOwner();
if (owner?.pid === process.pid)
await rm(lockPath, { force: true, recursive: true });
} catch (cause) {
console.error(
`Could not remove the ${lockName} command lock: ${cause instanceof Error ? cause.message : String(cause)}`,
);
}
}
function runCommand() {
return new Promise((resolveResult, reject) => {
child = spawn(command[0], command.slice(1), {
cwd: process.cwd(),
env: process.env,
shell: process.platform === "win32",
stdio: "inherit",
});
child.once("error", reject);
child.once("close", (code) => resolveResult({ code }));
});
}
function handleSignal(signal) {
if (shutdownSignal !== undefined) return;
shutdownSignal = signal;
if (child !== undefined && child.exitCode === null) child.kill(signal);
}
async function readOwner() {
try {
const parsed = JSON.parse(await readFile(ownerPath, "utf8"));
const pid =
typeof parsed === "object" && parsed !== null
? Reflect.get(parsed, "pid")
: undefined;
if (typeof pid !== "number" || !Number.isInteger(pid)) return null;
return { pid };
} catch (cause) {
if (isMissingFileError(cause)) return null;
throw cause;
}
}
function isProcessAlive(pid) {
try {
process.kill(pid, 0);
return true;
} catch (cause) {
return !isMissingProcessError(cause);
}
}
function isAlreadyExistsError(cause) {
return isNodeError(cause) && cause.code === "EEXIST";
}
function isMissingFileError(cause) {
return isNodeError(cause) && cause.code === "ENOENT";
}
function isMissingProcessError(cause) {
return isNodeError(cause) && cause.code === "ESRCH";
}
function isNodeError(cause) {
return typeof cause === "object" && cause !== null && "code" in cause;
}
+26 -10
View File
@@ -12,9 +12,9 @@ import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
const exec = promisify(execFile);
const verifierRun = createVerifierRun();
const manifestPath = await realpath(
resolve(process.argv[2] ?? "build/conformance/manifest.json"),
);
const manifestArgument = process.argv[2] ?? "build/conformance/manifest.json";
const manifestPath = await realpath(resolve(manifestArgument));
const sourceRoot = await resolveSourceRoot(manifestPath, process.argv[3]);
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
if (
manifest.schemaVersion !== 1 ||
@@ -39,9 +39,30 @@ if (v1 === undefined || v2 === undefined)
if (v1.artifactSha256 === v2.artifactSha256)
throw new Error("Version-pair artifacts unexpectedly have equal hashes");
process.stdout.write(
`${JSON.stringify({ verifier_run: await completeVerifierRun(verifierRun), verified: manifest.fixtures.length, manifestPath, platform: manifest.platform })}\n`,
`${JSON.stringify({ verifier_run: await completeVerifierRun(verifierRun), verified: manifest.fixtures.length, manifestPath, sourceRoot, platform: manifest.platform })}\n`,
);
async function resolveSourceRoot(manifestPath, declaredSourceRoot) {
const candidates = [
declaredSourceRoot === undefined
? null
: resolve(process.cwd(), declaredSourceRoot),
resolve(process.cwd(), "tests/conformance"),
resolve(dirname(dirname(dirname(manifestPath))), "tests/conformance"),
resolve(dirname(manifestPath), "../../tests/conformance"),
].filter((candidate) => candidate !== null);
for (const candidate of candidates) {
try {
return await realpath(candidate);
} catch {
// Try the next clean-checkout candidate.
}
}
throw new Error(
"Could not locate tests/conformance; pass its path as the second argument",
);
}
async function verifyFixture(fixture) {
const artifactPath = await confinedPath(root, fixture.artifact);
const sources = [];
@@ -51,12 +72,7 @@ async function verifyFixture(fixture) {
const content =
source.path === "generated/large.c"
? generateLargeFixture()
: await readFile(
await confinedPath(
resolve(process.cwd(), "tests/conformance"),
source.path,
),
);
: await readFile(await confinedPath(sourceRoot, source.path));
if (sha256(content) !== source.sha256)
throw new Error(
`${fixture.name}: source hash mismatch for ${source.path}`,
+3 -3
View File
@@ -344,11 +344,11 @@ try {
assert.equal(reconstructionImport.value.confidence, "inferred");
const runtimePlan = await planManagedRuntimeCorrelationEvidence(
{
policy: {
enabled: true,
policy: () => ({
status: "enabled",
roots: [workspace],
executablePath: runtimeExecutable,
},
}),
authority: runtimeAuthority.value,
},
{
+51
View File
@@ -0,0 +1,51 @@
import { readFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const readJson = async (path) =>
JSON.parse(await readFile(join(root, path), "utf8"));
const fail = (message) => {
throw new Error(`MCP Registry metadata is invalid: ${message}`);
};
const packageJson = await readJson("package.json");
const serverJson = await readJson("server.json");
const registryPackage = serverJson.packages?.[0];
if (packageJson.name !== "rea-agents") fail("unexpected npm package name");
if (typeof packageJson.version !== "string")
fail("package.json is missing a string version");
if (packageJson.mcpName !== "io.github.morluto/rea")
fail("package.json mcpName does not identify io.github.morluto/rea");
if (serverJson.name !== packageJson.mcpName)
fail("server.json name does not match package.json mcpName");
if (serverJson.version !== packageJson.version)
fail("server.json version does not match package.json version");
if (!Array.isArray(serverJson.packages) || serverJson.packages.length !== 1)
fail("server.json must describe exactly one npm package");
if (registryPackage === undefined)
fail("server.json is missing its npm package");
if (registryPackage.registryType !== "npm")
fail("server.json package must use the npm registry");
if (registryPackage.registryBaseUrl !== "https://registry.npmjs.org")
fail("server.json package must use the public npm registry");
if (registryPackage.identifier !== packageJson.name)
fail("server.json package identifier does not match package.json name");
if (registryPackage.version !== packageJson.version)
fail("server.json package version does not match package.json version");
if (registryPackage.runtimeHint !== "npx")
fail("server.json package must declare the npx runtime");
if (registryPackage.transport?.type !== "stdio")
fail("server.json package must declare stdio transport");
if (
!Array.isArray(registryPackage.packageArguments) ||
registryPackage.packageArguments.length !== 1 ||
registryPackage.packageArguments[0]?.type !== "positional" ||
registryPackage.packageArguments[0]?.value !== "mcp"
)
fail("server.json package must launch the existing mcp command");
process.stdout.write(
`MCP Registry metadata is aligned for ${packageJson.name}@${packageJson.version}.\n`,
);
+21 -13
View File
@@ -7,15 +7,12 @@ import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { MCP_STARTUP_POLICY } from "../dist/mcpStartupPolicy.js";
import * as prompts from "./verify-package-prompts.mjs";
import {
json,
verifyAvailableToolCatalog,
} from "./lib/verify-package-core.mjs";
import { json, verifyCompleteToolCatalog } from "./lib/verify-package-core.mjs";
const execute = promisify(execFile);
const verifyMcpToolsAndPrompts = async (client, mcpOptions) => {
await verifyAvailableToolCatalog(client, mcpOptions);
await verifyCompleteToolCatalog(client, mcpOptions);
await prompts.verifyPromptCatalog(client, mcpOptions, prompts.names);
await prompts.verifyPromptCompletion(client, mcpOptions, false);
};
@@ -43,11 +40,21 @@ const verifyMcpReplay = async (client, mcpOptions, investigationReplay) => {
};
const verifyMcpTargetFree = async (client, mcpOptions) => {
const names = (await client.listTools(undefined, mcpOptions)).tools.map(
({ name }) => name,
const status = await client.callTool(
{ name: "binary_session", arguments: { detail: "full" } },
mcpOptions,
);
if (names.includes("current_document"))
throw new Error("packaged target-free MCP advertised a target-bound tool");
const currentDocument =
status.structuredContent?.result?.tool_availability?.find(
({ name }) => name === "current_document",
);
if (
currentDocument?.available !== false ||
currentDocument.reason !== "target_required"
)
throw new Error(
"packaged target-free MCP omitted target-bound availability metadata",
);
};
const verifyMcpUnknownProvider = async (client, mcpOptions) => {
@@ -96,11 +103,12 @@ const verifyMcpOpenAndBind = async (client, mcpOptions) => {
};
const verifyMcpLinuxToolAvailability = async (client, mcpOptions) => {
const names = (await client.listTools(undefined, mcpOptions)).tools.map(
({ name }) => name,
const current = await client.callTool(
{ name: "current_document", arguments: {} },
mcpOptions,
);
if (names.includes("current_document"))
throw new Error("packaged Linux MCP advertised unavailable Hopper tools");
if (current.isError !== true)
throw new Error("packaged Linux MCP executed an unavailable Hopper tool");
};
const verifyMcpNonLinuxCurrentDocument = async (
+2
View File
@@ -26,6 +26,8 @@ export async function verifyPackagePack({ root, workspace }) {
packedManifest.bin?.["rea-agents"] !== "scripts/rea.mjs"
)
throw new Error("package did not expose both rea command entry points");
if (packedManifest.mcpName !== "io.github.morluto/rea")
throw new Error("package did not retain its MCP Registry ownership marker");
if (
packedFiles.some(
(path) => path.includes("__pycache__") || path.endsWith(".pyc"),
+1
View File
@@ -114,6 +114,7 @@ try {
observation_ms: 1_000,
include_accessibility_text: true,
include_script_sources: true,
source_capture_approved: true,
include_console_text: true,
console_text_approved: true,
include_json_body_shapes: true,
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env node
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { PlaywrightElectronActiveProvider } from "../dist/browser/PlaywrightElectronActiveProvider.js";
import { electronActiveObservationInputSchema } from "../dist/domain/electronActiveObservation.js";
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
const executable = process.env.REA_ELECTRON_EXECUTABLE;
if (executable === undefined || executable.length === 0)
throw new Error(
"REA_ELECTRON_EXECUTABLE must be an absolute path to an approved Electron executable",
);
const repositoryRoot = fileURLToPath(new URL("..", import.meta.url));
const applicationRoot =
process.env.REA_ELECTRON_APPLICATION_ROOT ??
join(repositoryRoot, "tests/conformance/readiness/electron");
const applicationPath =
process.env.REA_ELECTRON_APPLICATION_PATH ?? join(applicationRoot, "main.js");
const input = electronActiveObservationInputSchema.parse({
schema_version: 1,
executable_path: executable,
application_path: applicationPath,
application_root: applicationRoot,
actions: [
{ step_id: "ipc", kind: "click", selector: "#run", window_index: 0 },
{
step_id: "open-url",
kind: "deep-link",
delivery: "open-url",
url: "rea-readiness://open-url",
},
{
step_id: "second-instance",
kind: "deep-link",
delivery: "second-instance",
url: "rea-readiness://second-instance",
},
{ step_id: "renderer-reload", kind: "renderer-reload", window_index: 1 },
{ step_id: "renderer-crash", kind: "renderer-crash", window_index: 1 },
{
step_id: "renderer-restart",
kind: "wait",
duration_ms: 250,
window_index: 1,
},
],
approved: true,
});
const verifierRun = createVerifierRun();
const result = await new PlaywrightElectronActiveProvider().capture(input);
if (!result.ok) throw result.error;
const output = {
verifier_run: await completeVerifierRun(verifierRun),
electron: result.value.application,
actions: result.value.actions,
processes: result.value.processes,
ipc: result.value.ipc,
timeline: result.value.timeline,
windows: result.value.windows,
verified:
result.value.actions.every(({ status }) => status === "completed") &&
result.value.actions.some(
({ kind, window_index }) =>
kind === "renderer-reload" && window_index === 1,
) &&
result.value.actions.some(
({ kind, window_index }) =>
kind === "renderer-crash" && window_index === 1,
) &&
result.value.windows.length >= 2 &&
result.value.ipc.events.some(
({ kind, channel }) =>
kind === "main-handler-invocation" && channel === "readiness:echo",
) &&
result.value.timeline.events.some(
({ kind, event, phase }) =>
kind === "window-lifecycle" &&
event === "created" &&
phase === "completed",
) &&
result.value.timeline.events.some(
({ kind, event }) =>
kind === "process-lifecycle" && event === "utility-process-fork",
) &&
result.value.timeline.events.some(
({ kind, event, phase }) =>
kind === "shell-attempt" &&
event === "openExternal" &&
phase === "blocked",
) &&
result.value.timeline.events.some(
({ kind, event, phase }) =>
kind === "preload" && event === "configured" && phase === "completed",
) &&
result.value.timeline.events.some(
({ kind, event }) =>
kind === "protocol" &&
(event === "open-url" || event === "second-instance"),
) &&
result.value.timeline.events.some(
({ kind, event }) =>
kind === "process-lifecycle" && event === "child.spawn",
),
};
if (!output.verified)
throw new Error(
"real Electron verifier did not observe the readiness IPC round trip",
);
process.stdout.write(`${JSON.stringify(output)}\n`);
+24 -10
View File
@@ -52,7 +52,7 @@ const roots = [
),
];
const policy = {
enabled: true,
status: "enabled",
roots,
nodePath: process.execPath,
bubblewrapPath:
@@ -93,7 +93,7 @@ const authority = new PermissionAuthority(
),
);
const dependencies = {
policy,
policy: () => policy,
host: new SystemJavaScriptReplayHost(),
runner: new LinuxJavaScriptReplayRunner(),
authority,
@@ -131,18 +131,20 @@ const defaultInput = {
{ case_id: "plain", arguments: ["plain"] },
],
generator: { preset: "parser-boundaries", seed: 7, count: 4 },
approved: false,
};
const input =
const suppliedPlanInput =
suppliedInput === undefined
? undefined
: (({ approved: _approved, plan_digest: _planDigest, ...planInput }) =>
planInput)(suppliedInput);
const input =
suppliedPlanInput === undefined
? defaultInput
: {
...suppliedInput,
...suppliedPlanInput,
mode: "plan",
left: suppliedLeft,
...(suppliedRight === undefined ? {} : { right: suppliedRight }),
approved: false,
plan_digest: undefined,
reproducer_export: undefined,
};
@@ -177,7 +179,21 @@ if (
({ status }) => status === "unknown",
) === true
)
throw new Error("Real replay verifier received incomplete Evidence");
throw new Error(
`Real replay verifier received incomplete Evidence: ${JSON.stringify({
authority: evidence?.authority ?? null,
isolation: evidence?.environment?.isolation ?? null,
termination: evidence?.normalized_result?.termination ?? null,
cleanup: evidence?.normalized_result?.cleanup?.state ?? null,
outcomes:
evidence?.normalized_result?.outcomes?.map(({ outcome }) => outcome) ??
[],
stderr: evidence?.normalized_result?.stderr ?? null,
comparison_statuses:
evidence?.normalized_result?.comparison?.map(({ status }) => status) ??
[],
})}`,
);
if (suppliedInput !== undefined) {
process.stdout.write(
@@ -222,7 +238,6 @@ const runSingle = async (path, limits = undefined) => {
},
cases: [{ case_id: "probe", arguments: [] }],
...(limits === undefined ? {} : { limits }),
approved: false,
};
const proposed = await runControlledReplay(dependencies, manifest);
if (!proposed.ok || proposed.value.plan === null)
@@ -288,7 +303,6 @@ const differentialTimeoutInput = {
},
cases: [{ case_id: "probe", arguments: [] }],
limits: { wall_time_ms: 100 },
approved: false,
};
const differentialTimeoutPlan = await runControlledReplay(
dependencies,
+2 -2
View File
@@ -9,7 +9,7 @@ import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { verifyAvailableToolCatalog } from "./lib/verify-package-core.mjs";
import { verifyCompleteToolCatalog } from "./lib/verify-package-core.mjs";
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
const exec = promisify(execFile);
@@ -84,7 +84,7 @@ try {
let toolCount = 0;
try {
await client.connect(server);
toolCount = (await verifyAvailableToolCatalog(client)).length;
toolCount = (await verifyCompleteToolCatalog(client)).length;
} finally {
await client.close();
}
+30
View File
@@ -0,0 +1,30 @@
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.morluto/rea",
"title": "REA",
"description": "Reverse engineer anything from your terminal or agent with one CLI and MCP server.",
"websiteUrl": "https://github.com/morluto/rea",
"repository": {
"url": "https://github.com/morluto/rea",
"source": "github"
},
"version": "3.1.0",
"packages": [
{
"registryType": "npm",
"registryBaseUrl": "https://registry.npmjs.org",
"identifier": "rea-agents",
"version": "3.1.0",
"runtimeHint": "npx",
"transport": {
"type": "stdio"
},
"packageArguments": [
{
"type": "positional",
"value": "mcp"
}
]
}
]
}
+2 -2
View File
@@ -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: 114
catalog_digest: "e83a177f5eca63bbad17886a61f5a1a376d44afe862c51c56e5d38339f7d41f8"
tool_count: 116
catalog_digest: "bab5657d9525ec4f798d0a9ef6001412d9c11f77fb0ddc925c7076fd10921dc9"
---
# REA
+33 -16
View File
@@ -119,13 +119,10 @@ export const inspectAddressContext = async (
inline_comment: inlineComment,
bookmarks:
bookmarks.state === "available"
? {
state: "available",
value: matchingBookmarks(
bookmarks.value,
resolvedAddress(results[1], input.address),
),
}
? matchingBookmarks(
bookmarks.value,
resolvedAddress(results[1], input.address),
)
: bookmarks,
});
};
@@ -200,13 +197,33 @@ const resolvedAddress = (
: fallback;
};
const matchingBookmarks = (value: JsonValue, address: string): JsonValue => {
if (!Array.isArray(value)) return [];
return value.filter(
(bookmark) =>
typeof bookmark === "object" &&
bookmark !== null &&
!Array.isArray(bookmark) &&
bookmark.address === address,
);
const matchingBookmarks = (value: JsonValue, address: string): Facet => {
if (!Array.isArray(value))
return {
state: "unavailable",
reason: "list_bookmarks returned malformed output; expected an array.",
remediation:
"Retry list_bookmarks directly and report the provider output shape if it remains malformed.",
};
if (!value.every(isBookmark))
return {
state: "unavailable",
reason:
"list_bookmarks returned malformed entries; expected address and name strings.",
remediation:
"Retry list_bookmarks directly and report the provider output shape if it remains malformed.",
};
return {
state: "available",
value: value.filter((bookmark) => bookmark.address === address),
};
};
const isBookmark = (
value: JsonValue,
): value is { readonly address: string; readonly name: string } =>
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
typeof value.address === "string" &&
typeof value.name === "string";
@@ -12,6 +12,7 @@ import {
type TraceApplicationFeatureRequest,
type TraceJavaScriptSemanticsRequest,
} from "../contracts/applicationWorkflowInputContracts.js";
import type { Evidence } from "../domain/evidence.js";
import { AnalysisInputError, type AnalysisError } from "../domain/errors.js";
import { projectInputIssues } from "../domain/inputIssueProjection.js";
import { compareApplicationVersionsInputSchema } from "../domain/javascriptApplicationVersionComparisonSchemas.js";
@@ -69,11 +70,7 @@ export const resolveTraceApplicationFeatureRequestValidated = (
input: TraceApplicationFeatureRequest,
lookup?: EvidenceLookup,
): Result<TraceInput, AnalysisError> => {
const application = graphEvidence(
input.application,
input.application_evidence_id,
lookup,
);
const application = graphEvidence(input, lookup);
if (!application.ok) return application;
const native = resolveEvidenceReferences(
lookup,
@@ -109,11 +106,7 @@ export const resolveTraceJavaScriptSemanticsRequestValidated = (
input: TraceJavaScriptSemanticsRequest,
lookup?: EvidenceLookup,
): Result<SemanticTraceInput, AnalysisError> => {
const application = graphEvidence(
input.application,
input.application_evidence_id,
lookup,
);
const application = graphEvidence(input, lookup);
if (!application.ok) return application;
const raw = { application: application.value, query: input.query };
const parsed = traceJavaScriptSemanticsInputSchema.safeParse(raw);
@@ -138,9 +131,19 @@ export const resolveCompareApplicationVersionsRequestValidated = (
input: CompareApplicationVersionsRequest,
lookup?: EvidenceLookup,
): Result<ComparisonInput, AnalysisError> => {
const left = graphEvidence(input.left, input.left_evidence_id, lookup);
const left = graphEvidence(
"left" in input
? { application: input.left }
: { application_evidence_id: input.left_evidence_id },
lookup,
);
if (!left.ok) return left;
const right = graphEvidence(input.right, input.right_evidence_id, lookup);
const right = graphEvidence(
"right" in input
? { application: input.right }
: { application_evidence_id: input.right_evidence_id },
lookup,
);
if (!right.ok) return right;
const leftNative = resolveEvidenceReferences(
lookup,
@@ -190,11 +193,7 @@ export const resolveCompareSourceToBundleRequestValidated = (
input: CompareSourceToBundleRequest,
lookup?: EvidenceLookup,
): Result<SourceToBundleInput, AnalysisError> => {
const application = graphEvidence(
input.application,
input.application_evidence_id,
lookup,
);
const application = graphEvidence(input, lookup);
if (!application.ok) return application;
const raw = {
reference: input.reference,
@@ -226,9 +225,19 @@ export const resolveCompareJavaScriptExportShapesRequestValidated = (
input: CompareJavaScriptExportShapesRequest,
lookup?: EvidenceLookup,
): Result<ExportShapeComparisonInput, AnalysisError> => {
const left = graphEvidence(input.left, input.left_evidence_id, lookup);
const left = graphEvidence(
"left" in input
? { application: input.left }
: { application_evidence_id: input.left_evidence_id },
lookup,
);
if (!left.ok) return left;
const right = graphEvidence(input.right, input.right_evidence_id, lookup);
const right = graphEvidence(
"right" in input
? { application: input.right }
: { application_evidence_id: input.right_evidence_id },
lookup,
);
if (!right.ok) return right;
const raw = {
left: left.value,
@@ -249,19 +258,15 @@ export const resolveCompareJavaScriptExportShapesRequestValidated = (
};
const graphEvidence = (
evidence:
| TraceApplicationFeatureRequest["application"]
| TraceJavaScriptSemanticsRequest["application"]
| CompareApplicationVersionsRequest["left"]
| CompareJavaScriptExportShapesRequest["left"]
| CompareSourceToBundleRequest["application"],
evidenceId: string | undefined,
source:
| { readonly application: Evidence }
| { readonly application_evidence_id: string },
lookup: EvidenceLookup | undefined,
) => {
if (evidence !== undefined) return ok(evidence);
if ("application" in source) return ok(source.application);
const resolved = resolveEvidenceReferences(
lookup,
evidenceId === undefined ? [] : [evidenceId],
[source.application_evidence_id],
APPLICATION_GRAPH_IDENTITIES,
);
return resolved.ok ? ok(resolved.value[0]) : resolved;
@@ -0,0 +1,41 @@
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import type {
ArtifactIntegrityPolicy,
ArtifactNativeMountPolicy,
} from "./types.js";
/** Parsed caller intent for integrity mismatch handling. */
export type ArtifactIntegrityIntent =
| { readonly mode: "fail" }
| {
readonly mode: "record-and-continue";
readonly maxMismatches: number;
};
/** Resolve caller approval and operator policy before native reader selection. */
export const resolveNativeMountPolicy = (
approved: boolean,
enabled: boolean,
): ArtifactNativeMountPolicy => {
if (!approved) return { status: "disabled" };
if (!enabled)
throw new ArtifactReaderFailure(
"unavailable",
"Native DMG mounting is disabled by operator policy",
);
return { status: "approved" };
};
/** Resolve parsed caller intent and operator policy before artifact scanning. */
export const resolveArtifactIntegrityPolicy = (
intent: ArtifactIntegrityIntent,
enabled: boolean,
): ArtifactIntegrityPolicy => {
if (intent.mode === "fail") return intent;
if (!enabled)
throw new ArtifactReaderFailure(
"policy",
"Integrity continuation requires explicit approval and operator policy",
);
return intent;
};
+2 -10
View File
@@ -1,8 +1,5 @@
import { AsarArtifactReader } from "../../artifacts/AsarArtifactReader.js";
import {
ArtifactReaderFailure,
type ArtifactReader,
} from "../../artifacts/ArtifactReader.js";
import type { ArtifactReader } from "../../artifacts/ArtifactReader.js";
import { DirectoryArtifactReader } from "../../artifacts/DirectoryArtifactReader.js";
import { MachOSliceArtifactReader } from "../../artifacts/MachOSliceArtifactReader.js";
import { NativeDmgArtifactReader } from "../../artifacts/NativeDmgArtifactReader.js";
@@ -32,12 +29,7 @@ export const createReader = async (
? new MachOSliceArtifactReader(path)
: undefined;
case "dmg":
if (!nativeMount.nativeMountApproved) return undefined;
if (!nativeMount.nativeMountEnabled)
throw new ArtifactReaderFailure(
"unavailable",
"Native DMG mounting is disabled by operator policy",
);
if (nativeMount.status === "disabled") return undefined;
return NativeDmgArtifactReader.create(path, signal);
default:
return undefined;
@@ -44,14 +44,6 @@ export const scanCanonicalArtifactInventory = async (
options: ArtifactInventoryOptions = {},
): Promise<ArtifactInventorySnapshot> => {
const integrity = options.integrity ?? STRICT_INTEGRITY_POLICY;
if (
integrity.mode === "record-and-continue" &&
(!integrity.approved || !integrity.enabled)
)
throw new ArtifactReaderFailure(
"policy",
"Integrity continuation requires explicit approval and operator policy",
);
const metadata = await lstat(path);
const rootFormat = await classifyRoot(path, metadata.isDirectory());
const rootDigest = metadata.isDirectory()
+12 -12
View File
@@ -166,19 +166,19 @@ const digestArtifactEntry = async (
context.totalBytes += digest.bytes;
const mismatched =
entry.declaredSha256 !== null && entry.declaredSha256 !== digest.sha256;
if (mismatched && context.integrity.mode === "fail")
throw new ArtifactReaderFailure(
"integrity",
`Artifact integrity metadata disagrees with content: ${logicalPath}`,
undefined,
{
logicalPath,
declaredSha256: entry.declaredSha256,
calculatedSha256: digest.sha256,
unpacked: entry.unpacked,
},
);
if (mismatched && entry.declaredSha256 !== null) {
if (context.integrity.mode === "fail")
throw new ArtifactReaderFailure(
"integrity",
`Artifact integrity metadata disagrees with content: ${logicalPath}`,
undefined,
{
logicalPath,
declaredSha256: entry.declaredSha256,
calculatedSha256: digest.sha256,
unpacked: entry.unpacked,
},
);
if (context.pendingContradictions.length >= context.integrity.maxMismatches)
throw new ArtifactReaderFailure(
"limit",
+12 -17
View File
@@ -13,30 +13,25 @@ export interface InventoryPageInput {
readonly edgeLimit: number;
}
/** Per-request authority combined with operator-owned native mount policy. */
export interface ArtifactNativeMountPolicy {
readonly nativeMountApproved: boolean;
readonly nativeMountEnabled: boolean;
}
/** Resolved native mount authority admitted to the artifact reader. */
export type ArtifactNativeMountPolicy =
| { readonly status: "disabled" }
| { readonly status: "approved" };
export const NATIVE_MOUNT_DISABLED: ArtifactNativeMountPolicy = {
nativeMountApproved: false,
nativeMountEnabled: false,
status: "disabled",
};
/** Explicit caller approval bounded by operator-owned integrity policy. */
export interface ArtifactIntegrityPolicy {
readonly mode: "fail" | "record-and-continue";
readonly approved: boolean;
readonly enabled: boolean;
readonly maxMismatches: number;
}
/** Resolved integrity behavior admitted to the artifact scanner. */
export type ArtifactIntegrityPolicy =
| { readonly mode: "fail" }
| {
readonly mode: "record-and-continue";
readonly maxMismatches: number;
};
export const STRICT_INTEGRITY_POLICY: ArtifactIntegrityPolicy = {
mode: "fail",
approved: false,
enabled: false,
maxMismatches: 1,
};
/** Options shared by artifact inventory scans. */
+16 -20
View File
@@ -74,9 +74,9 @@ export class BinarySession
#transition: Promise<void> = Promise.resolve();
readonly #calls = new Set<Promise<unknown>>();
readonly #providerRouter: SessionProviderRouter;
readonly #runtimeAvailability = new Map<
readonly #runtimeUnavailability = new Map<
string,
{ readonly available: boolean; readonly reason: string | null }
{ readonly reason: string }
>();
readonly #availabilityListeners = new Set<() => void | Promise<void>>();
@@ -272,7 +272,7 @@ export class BinarySession
target: this.#active?.target,
route: this.#currentRoute(),
router: this.#providerRouter,
runtimeAvailability: this.#runtimeAvailability,
runtimeUnavailability: this.#runtimeUnavailability,
runId: this.#active?.runId,
runtimeLineageSnapshots:
this.#active?.client.runtimeLineageSnapshots?.() ?? [],
@@ -344,12 +344,12 @@ export class BinarySession
result: Result<AnalysisExecution, AnalysisError>,
): void {
if (result.ok) {
if (this.#setRuntimeAvailability(operation, true, null))
if (this.#markRuntimeAvailable(operation))
this.#emitAvailabilityChanged();
return;
}
if (result.error._tag === "AnalysisCapabilityUnavailableError") {
if (this.#setRuntimeAvailability(operation, false, result.error.message))
if (this.#markRuntimeUnavailable(operation, result.error.message))
this.#emitAvailabilityChanged();
return;
}
@@ -367,32 +367,28 @@ export class BinarySession
for (const descriptor of capabilities?.values() ?? [])
if (providerId !== undefined && descriptor.provider.id === providerId)
changed =
this.#setRuntimeAvailability(
this.#markRuntimeUnavailable(
descriptor.operation,
false,
"Provider became unavailable during this session.",
) || changed;
if (changed) this.#emitAvailabilityChanged();
}
}
#setRuntimeAvailability(
operation: string,
available: boolean,
reason: string | null,
): boolean {
const current = this.#runtimeAvailability.get(operation);
if (current?.available === available && current.reason === reason)
return false;
if (available && current === undefined) return false;
if (available) this.#runtimeAvailability.delete(operation);
else this.#runtimeAvailability.set(operation, { available, reason });
#markRuntimeAvailable(operation: string): boolean {
return this.#runtimeUnavailability.delete(operation);
}
#markRuntimeUnavailable(operation: string, reason: string): boolean {
const current = this.#runtimeUnavailability.get(operation);
if (current?.reason === reason) return false;
this.#runtimeUnavailability.set(operation, { reason });
return true;
}
#clearRuntimeAvailability(): void {
if (this.#runtimeAvailability.size === 0) return;
this.#runtimeAvailability.clear();
if (this.#runtimeUnavailability.size === 0) return;
this.#runtimeUnavailability.clear();
this.#emitAvailabilityChanged();
}
+3
View File
@@ -34,6 +34,8 @@ export interface EvidenceBundleSnapshot {
readonly bytes: number;
readonly records: number;
readonly unknowns: number;
readonly scope: "session";
readonly survivesSession: false;
readonly uri: string;
}
@@ -60,6 +62,7 @@ export interface BinarySessionPort extends AnalysisOperationPort {
evidenceById(evidenceId: string): Evidence | undefined;
exportEvidenceBundle(): EvidenceBundle;
snapshotEvidenceBundle(): Result<EvidenceBundleSnapshot, EvidenceLimitError>;
releaseEvidenceBundle(bundleDigest: string): boolean;
retainedEvidenceBundle(digest: string): string | undefined;
importEvidenceBundle(
bundle: unknown,
+29 -4
View File
@@ -129,6 +129,8 @@ export abstract class BinarySessionRecords {
bytes: Buffer.byteLength(existing ?? encoded),
records: bundle.records.length,
unknowns: bundle.unknowns.length,
scope: "session",
survivesSession: false,
uri: `rea://evidence-bundle/${digest}`,
});
}
@@ -137,12 +139,23 @@ export abstract class BinarySessionRecords {
return this.#retainedBundles.get(digest);
}
/** Release one immutable bundle so bounded retention has an explicit recovery path. */
releaseEvidenceBundle(bundleDigest: string): boolean {
const encoded = this.#retainedBundles.get(bundleDigest);
if (encoded === undefined) return false;
this.#retainedBundles.delete(bundleDigest);
this.#retainedBundleBytes -= Buffer.byteLength(encoded);
this.#emitSnapshotChanged();
return true;
}
importEvidenceBundle(
bundle: unknown,
): Result<number, EvidenceIntegrityError | EvidenceLimitError> {
const imported = this.#evidence.import(bundle);
if (imported.ok && imported.value > 0) this.#emitSnapshotChanged();
return imported;
if (!imported.ok) return imported;
if (imported.value.changed) this.#emitSnapshotChanged();
return ok(imported.value.recordsAdded);
}
protected abstract activeAnalysisBinding(): ActiveAnalysisBinding | undefined;
@@ -187,7 +200,10 @@ export abstract class BinarySessionRecords {
active === undefined
? undefined
: { target: active.target, profile: active.profile },
(bundle) => this.#evidence.import(bundle),
(bundle) => {
const imported = this.#evidence.import(bundle);
return imported.ok ? ok(imported.value.recordsAdded) : imported;
},
);
if (imported.ok) this.#emitSnapshotChanged();
return imported;
@@ -253,7 +269,16 @@ export abstract class BinarySessionRecords {
}
#emitSnapshotChanged(): void {
for (const listener of this.#snapshotListeners) void listener();
for (const listener of this.#snapshotListeners) {
try {
const notification = listener();
if (notification !== undefined)
void notification.catch(() => undefined);
} catch {
// External resource observers are best-effort; one callback must not
// make a committed evidence mutation appear to fail.
}
}
}
retainInvestigationWorkspace(
+13 -7
View File
@@ -14,9 +14,9 @@ interface BinarySessionStatusInput {
readonly target: BinaryTarget | undefined;
readonly route: SessionProviderRoute;
readonly router: SessionProviderRouter;
readonly runtimeAvailability: ReadonlyMap<
readonly runtimeUnavailability: ReadonlyMap<
string,
{ readonly available: boolean; readonly reason: string | null }
{ readonly reason: string }
>;
readonly runId: string | undefined;
readonly runtimeLineageSnapshots: readonly ProviderRuntimeLineageSnapshot[];
@@ -28,7 +28,7 @@ export const binarySessionStatus = ({
target,
route,
router,
runtimeAvailability,
runtimeUnavailability,
runId,
runtimeLineageSnapshots,
requestActivitySnapshots,
@@ -38,10 +38,16 @@ export const binarySessionStatus = ({
const providers = router.providerIdentities(route).map(providerSummary);
const capabilities = [...(route.capabilities?.values() ?? [])]
.sort((left, right) => left.operation.localeCompare(right.operation))
.map((descriptor) => ({
...descriptor,
...runtimeAvailability.get(descriptor.operation),
}))
.map((descriptor) => {
const unavailable = runtimeUnavailability.get(descriptor.operation);
return unavailable === undefined
? descriptor
: {
...descriptor,
available: false as const,
reason: unavailable.reason,
};
})
.map(capabilityStatus);
const analysisProviderBinding =
route.binding === null
+3 -7
View File
@@ -93,11 +93,7 @@ const browserParameters = (
input: BrowserEvidenceInput,
): EvidenceObservation["parameters"] => {
if (!("cdp_endpoint" in input)) {
if (
"before_scenario" in input &&
input.before_scenario !== undefined &&
input.after_scenario !== undefined
)
if ("before_scenario" in input)
return {
comparison_kind: "browser_scenario",
before_browser: input.before_scenario.browser,
@@ -166,9 +162,9 @@ const browserParameters = (
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
source_capture_approved: input.source_capture_approved,
...(input.include_script_sources && "analysis_limits" in input
? {
source_capture_approved: input.source_capture_approved,
fetch_source_maps: input.fetch_source_maps,
source_map_fetch_approved: input.source_map_fetch_approved,
analysis_limits: input.analysis_limits,
@@ -0,0 +1,47 @@
import type { ClientFeatureAvailability } from "../contracts/toolOutputSchemaPrimitives.js";
type ClientFeatureName = keyof ClientFeatureAvailability;
/** Empty MCP client feature declaration used when the caller supplies none. */
export 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"],
},
};
/** Project a tool's client requirements against the negotiated feature set. */
export 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],
),
};
};
+191 -111
View File
@@ -1,39 +1,31 @@
import { z } from "zod";
import type { ClientFeatureAvailability } from "../contracts/toolOutputSchemaPrimitives.js";
import {
providerCapability,
type ClientFeatureAvailability,
type ProviderCapability,
type ToolAvailability,
type ToolAvailabilityMode,
type ToolUnavailabilityReason,
} from "../contracts/toolOutputSchemaPrimitives.js";
import type { ToolKind } from "../contracts/toolContractTypes.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { GENERATED_MCP_TOOL_CATALOG } from "../generatedMcpToolCatalog.js";
import {
clientRequirementsFor,
NO_CLIENT_FEATURES,
} from "./CapabilityClientRequirements.js";
const statusSchema = z.object({
open: z.boolean(),
kind: z.enum(["executable", "database", "archive", "artifact"]).optional(),
format: z.string().optional(),
capabilities: z.array(
z.object({
operation: z.string(),
available: z.boolean(),
reason: z.string().nullable(),
availability_code: z.string().nullable().optional(),
}),
),
capabilities: z.array(providerCapability),
});
type ToolAvailabilityReason =
| "available"
| "client_capability_missing"
| "target_required"
| "provider_missing"
| "provider_unavailable"
| "target_unsupported"
| "unsupported_host"
| "policy_disabled";
type ToolAvailabilityReason = "available" | ToolUnavailabilityReason;
type ProviderDescriptor = {
readonly available: boolean;
readonly reason: string | null;
readonly availability_code?: string | null;
};
type ProviderDescriptor = ProviderCapability;
export type AvailabilityPolicy = {
readonly processCaptureEnabled: boolean;
readonly evidenceFileRoots: number;
@@ -41,38 +33,31 @@ export type AvailabilityPolicy = {
readonly browserObservationEnabled?: boolean;
readonly browserScenarioEnabled?: boolean;
readonly electronObservationEnabled?: boolean;
readonly electronAutomationEnabled?: 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;
type AvailabilityFacts = {
readonly defaultModeAvailable?: boolean;
readonly modes?: readonly ToolAvailabilityMode[];
};
type Availability = AvailabilityFacts &
(
| { readonly reason: "available"; readonly remediation: null }
| {
readonly reason: ToolUnavailabilityReason;
readonly remediation: string | null;
}
);
type NavigationModeResult =
| (Extract<ToolAvailabilityMode, { available: true }> & {
readonly reason: "available";
})
| (Extract<ToolAvailabilityMode, { available: false }> & {
readonly reason: ToolUnavailabilityReason;
});
type AvailabilityContext = {
readonly name: string;
readonly kind: ToolKind;
@@ -111,34 +96,32 @@ const ENHANCED_REQUIREMENTS: Readonly<Record<string, readonly string[]>> = {
],
};
const SESSION_COMPOSITION_REQUIREMENTS: Readonly<
Record<string, readonly string[]>
> = {
get_navigation_context: [
"current_document",
"current_address",
"resolve_containing_procedure",
],
};
const NAVIGATION_CONTEXT_MODES = [
{
name: "current_selection",
requirements: [
"current_document",
"current_address",
"resolve_containing_procedure",
],
},
{
name: "explicit_document",
requirements: ["current_address", "resolve_containing_procedure"],
},
] as const;
/** Build stable per-operation availability for discovery and tool visibility. */
export const buildCapabilityInventory = (
sessionStatus: JsonValue,
policy: AvailabilityPolicy,
clientFeatures: ClientFeatureAvailability = NO_CLIENT_FEATURES,
) => {
): readonly ToolAvailability[] => {
const status = statusSchema.parse(sessionStatus);
const descriptors = new Map<string, ProviderDescriptor>(
status.capabilities.map((descriptor) => [
descriptor.operation,
{
available: descriptor.available,
reason: descriptor.reason,
availability_code: descriptor.availability_code ?? null,
},
]),
status.capabilities.map((descriptor) => [descriptor.operation, descriptor]),
);
return GENERATED_MCP_TOOL_CATALOG.map((contract) => {
return GENERATED_MCP_TOOL_CATALOG.map((contract): ToolAvailability => {
const availability = availabilityFor({
name: contract.name,
kind: contract.kind,
@@ -152,16 +135,15 @@ export const buildCapabilityInventory = (
clientFeatures,
);
const clientBlocked = clientRequirements.missing_required.length > 0;
return {
const facts = {
name: contract.name,
surface: contract.kind,
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,
...(availability.defaultModeAvailable === undefined
? {}
: { default_mode_available: availability.defaultModeAvailable }),
...(availability.modes === undefined
? {}
: { modes: [...availability.modes] }),
client_requirements: clientRequirements,
effects: { ...contract.effects },
annotations: {
@@ -171,33 +153,32 @@ export const buildCapabilityInventory = (
open_world: contract.annotations.openWorldHint ?? true,
},
};
if (clientBlocked)
return {
...facts,
available: false,
reason: "client_capability_missing",
remediation: `Use an MCP client that supports: ${clientRequirements.missing_required.join(", ")}.`,
};
return availability.reason === "available"
? {
...facts,
available: true,
reason: availability.reason,
remediation: null,
}
: {
...facts,
available: false,
reason: availability.reason,
remediation: availability.remediation,
};
}).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 sessionComposition = SESSION_COMPOSITION_REQUIREMENTS[context.name];
if (sessionComposition !== undefined)
return composedAvailabilityFor(sessionComposition, context.descriptors);
if (context.name === "get_navigation_context")
return navigationContextAvailability(context.descriptors);
const javascriptApplication = javascriptApplicationAvailability(context);
if (javascriptApplication !== null) return javascriptApplication;
const policyDecision = policyAvailability(context);
@@ -262,14 +243,8 @@ const policyAvailability = ({
reason: "policy_disabled",
remediation: "Configure or grant an evidence_read root.",
};
if (kind === "electron-provider")
return policy.electronObservationEnabled === true
? { reason: "available", remediation: null }
: {
reason: "policy_disabled",
remediation:
"Enable Electron observation and configure a loopback CDP endpoint and canonical file roots.",
};
const electron = electronPolicyAvailability(name, kind, policy);
if (electron !== null) return electron;
if (kind === "runtime-provider")
return policy.v8InspectorObservationEnabled === true
? { reason: "available", remediation: null }
@@ -282,6 +257,29 @@ const policyAvailability = ({
return null;
};
const electronPolicyAvailability = (
name: string,
kind: ToolKind,
policy: AvailabilityPolicy,
): Availability | null => {
if (kind !== "electron-provider") return null;
if (name === "capture_electron_scenario")
return policy.electronAutomationEnabled === true
? { reason: "available", remediation: null }
: {
reason: "policy_disabled",
remediation:
"Enable active Electron automation and configure exact executable and application roots.",
};
return policy.electronObservationEnabled === true
? { reason: "available", remediation: null }
: {
reason: "policy_disabled",
remediation:
"Enable Electron observation and configure a loopback CDP endpoint and canonical file roots.",
};
};
const browserPolicyAvailability = (
name: string,
kind: ToolKind,
@@ -369,10 +367,7 @@ const providerAvailability = ({
const composedAvailability = (
name: string,
descriptors: ReadonlyMap<string, ProviderDescriptor>,
): {
readonly reason: ToolAvailabilityReason;
readonly remediation: string | null;
} => {
): Availability => {
const requirements = ENHANCED_REQUIREMENTS[name];
if (requirements === undefined)
return {
@@ -406,3 +401,88 @@ const composedAvailabilityFor = (
};
return { reason: "available", remediation: null };
};
const navigationContextAvailability = (
descriptors: ReadonlyMap<string, ProviderDescriptor>,
): Availability => {
const modeResults: NavigationModeResult[] = NAVIGATION_CONTEXT_MODES.map(
(mode) => {
const availability = composedAvailabilityFor(
mode.requirements,
descriptors,
);
if (availability.reason === "available")
return {
name: mode.name,
available: true,
reason: availability.reason,
missing_operations: [],
remediation: null,
};
return {
name: mode.name,
available: false,
reason: availability.reason,
missing_operations: mode.requirements.filter((operation) => {
const descriptor = descriptors.get(operation);
return descriptor === undefined || descriptor.available === false;
}),
remediation: availability.remediation,
};
},
);
const modes: ToolAvailabilityMode[] = modeResults.map((mode) =>
mode.available
? {
name: mode.name,
available: true,
missing_operations: [],
remediation: null,
}
: {
name: mode.name,
available: false,
missing_operations: [...mode.missing_operations],
remediation: mode.remediation,
},
);
const availableMode = modeResults.find((mode) => mode.available);
const failures = modeResults.flatMap((mode) =>
mode.available ? [] : [mode],
);
const failure = failures.sort(
(left, right) =>
navigationFailurePriority(left.reason) -
navigationFailurePriority(right.reason),
)[0];
const specificFailureRemediation =
failure?.reason === "provider_missing" ? undefined : failure?.remediation;
const facts = {
defaultModeAvailable: modes[0]?.available ?? false,
modes,
};
if (availableMode !== undefined)
return { ...facts, reason: "available", remediation: null };
return {
...facts,
reason: failure?.reason ?? "provider_missing",
remediation:
specificFailureRemediation ??
`Configure a provider for one of: ${modes
.flatMap((mode) => mode.missing_operations)
.join(", ")}.`,
};
};
const navigationFailurePriority = (reason: ToolAvailabilityReason): number => {
switch (reason) {
case "unsupported_host":
return 0;
case "provider_unavailable":
return 1;
case "provider_missing":
return 2;
default:
return 3;
}
};
+10 -6
View File
@@ -20,6 +20,7 @@ import {
} from "./ArtifactInventory.js";
import { scanAuthorizedArtifactInventory } from "./AuthorizedArtifactInventory.js";
import { ARTIFACT_GRAPH_PROVIDER } from "./InvestigationProviders.js";
import { resolveArtifactIntegrityPolicy } from "./ArtifactInventory/policy.js";
export const AUTOMATIC_RUN_LIMITATION =
"Automatic cross-version runs compare shipped artifact structure only; they do not execute either version.";
@@ -76,12 +77,15 @@ export const scanVersions = async (
if (signal?.aborted === true)
return err(new AnalysisCancelledError("find_changed_behavior"));
const limits = artifactLimits(input.options);
const integrity: ArtifactIntegrityPolicy = {
mode: input.integrity_policy,
approved: input.integrity_continue_approved,
enabled: integrityContinueEnabled,
maxMismatches: input.max_integrity_mismatches,
};
const integrity = resolveArtifactIntegrityPolicy(
input.integrity_policy === "fail"
? { mode: "fail" }
: {
mode: input.integrity_policy,
maxMismatches: input.max_integrity_mismatches,
},
integrityContinueEnabled,
);
return ok(
await scanVersionInventories({
leftPath: input.left_path,
+1 -1
View File
@@ -67,7 +67,7 @@ export interface CrossVersionInvestigationExecution {
/** Run or resume a deterministic cross-version artifact investigation. */
export const runCrossVersionInvestigation = async (
input: CrossVersionInvestigationInput,
input: unknown,
policy: EvidenceFilePolicy,
execution: CrossVersionInvestigationExecution,
): Promise<Result<CrossVersionInvestigationOutcome, AnalysisError>> => {
+81
View File
@@ -0,0 +1,81 @@
import type { ProviderIdentity } from "./AnalysisProvider.js";
import type { Evidence, EvidenceObservation } from "../domain/evidence.js";
import { createEvidence } from "../domain/evidence.js";
import type {
ElectronActiveObservationInput,
ElectronActiveObservationResult,
} from "../domain/electronActiveObservation.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { digestJson } from "./JavaScriptReplayPlanning.js";
/** Create Evidence v2 without retaining arbitrary runtime argument values. */
export const createElectronActiveEvidence = (
input: ElectronActiveObservationInput,
result: ElectronActiveObservationResult,
provider: ProviderIdentity,
): Evidence =>
createEvidence(undefined, provider, {
predicateType: "rea.electron-active-scenario/v1",
operation: "capture_electron_scenario",
parameters: parameters(input),
result: jsonValueSchema.parse(result),
confidence: "observed",
authority: "controlled-replay",
environment: {
id: `${result.application.electron_version}@${process.platform}`,
platform: process.platform,
architecture: process.arch,
isolation: "none",
},
limitations: result.limitations,
});
const scenarioProjection = (
input: ElectronActiveObservationInput,
): EvidenceObservation["parameters"] => ({
approved: input.approved,
schema_version: input.schema_version,
executable_path: input.executable_path,
application_path: input.application_path,
application_root: input.application_root,
args: redactArguments(input.args),
actions: input.actions.map(({ step_id, kind }) => ({ step_id, kind })),
limits: input.limits,
});
const parameters = (
input: ElectronActiveObservationInput,
): EvidenceObservation["parameters"] => ({
...scenarioProjection(input),
scenario_sha256: digestJson(scenarioProjection(input)),
});
const redactArguments = (arguments_: readonly string[]): string[] => {
let redactNext = false;
return arguments_.map((argument) => {
if (redactNext) {
redactNext = false;
return "<redacted>";
}
if (sensitiveFlag.test(argument)) {
redactNext = true;
return argument;
}
return redactArgument(argument);
});
};
const sensitiveFlag =
/^--?(?:password|token|secret|api[-_]?key|authorization|cookie)$/iu;
const redactArgument = (argument: string): string => {
if (
/^--?(?:password|token|secret|api[-_]?key|authorization|cookie)=/iu.test(
argument,
)
)
return `${argument.slice(0, argument.indexOf("=") + 1)}<redacted>`;
return argument.length > 256
? `${argument.slice(0, 256)}<redacted>`
: argument;
};
@@ -0,0 +1,16 @@
import type { ExecutionOptions, ProviderIdentity } from "./AnalysisProvider.js";
import type { AnalysisError } from "../domain/errors.js";
import type {
ElectronActiveObservationInput,
ElectronActiveObservationResult,
} from "../domain/electronActiveObservation.js";
import type { Result } from "../domain/result.js";
/** Provider-neutral boundary for owned Electron runtime experiments. */
export interface ElectronActiveObservationPort {
identity(): ProviderIdentity;
capture(
input: ElectronActiveObservationInput,
options?: ExecutionOptions,
): Promise<Result<ElectronActiveObservationResult, AnalysisError>>;
}
@@ -0,0 +1,132 @@
import { realpath } from "node:fs/promises";
import type { ExecutionOptions } from "./AnalysisProvider.js";
import type { ElectronActiveObservationPort } from "./ElectronActiveObservationPort.js";
import type { PermissionAuthority } from "./PermissionAuthority.js";
import { digestJson } from "./JavaScriptReplayPlanning.js";
import {
AnalysisCapabilityUnavailableError,
AnalysisInputError,
AnalysisProtocolError,
PermissionRequiredError,
type AnalysisError,
} from "../domain/errors.js";
import type { Evidence } from "../domain/evidence.js";
import type { ElectronActiveObservationInput } from "../domain/electronActiveObservation.js";
import { isPathContained } from "../domain/permissionPolicy.js";
import { err, type Result } from "../domain/result.js";
import { createElectronActiveEvidence } from "./ElectronActiveEvidence.js";
const OPERATION = "capture_electron_scenario" as const;
const PROVIDER_ID = "rea-playwright-electron-active";
const canonicalizeInput = async (
input: ElectronActiveObservationInput,
): Promise<Result<ElectronActiveObservationInput, AnalysisError>> => {
try {
const [executablePath, applicationPath, applicationRoot] =
await Promise.all([
realpath(input.executable_path),
realpath(input.application_path),
realpath(input.application_root),
]);
if (!isPathContained(applicationRoot, applicationPath))
return err(
new AnalysisInputError(OPERATION, undefined, [
{
path: ["application_path"],
reason: "invalid_value",
message:
"application_path must remain beneath application_root after symlink resolution",
},
]),
);
return {
ok: true,
value: {
...input,
executable_path: executablePath,
application_path: applicationPath,
application_root: applicationRoot,
},
};
} catch (cause: unknown) {
return err(
new AnalysisInputError(OPERATION, { cause }, [
{
path: ["application_path"],
reason: "invalid_format",
message:
"Electron executable, application, and root must resolve to existing paths",
},
]),
);
}
};
const authorize = async (
authority: PermissionAuthority | undefined,
input: ElectronActiveObservationInput,
): Promise<Result<ElectronActiveObservationInput, AnalysisError>> => {
const canonical = await canonicalizeInput(input);
if (!canonical.ok) return canonical;
if (authority === undefined)
return err(
new AnalysisCapabilityUnavailableError(
PROVIDER_ID,
OPERATION,
"active Electron observation permission policy is not configured",
),
);
const authorized = await authority.authorize(
{
capability: "electron_automate",
roots: [canonical.value.application_root],
executables: [canonical.value.executable_path],
environment_names: [],
network: "external",
mount: false,
operation_identity: `${OPERATION}:${digestJson(canonical.value)}`,
},
"write",
);
if (!authorized.ok)
return err(
authorized.error instanceof PermissionRequiredError
? authorized.error
: new AnalysisProtocolError(authorized.error.message, {
cause: authorized.error,
}),
);
return canonical;
};
/** Authorize and execute one bounded, provider-owned Electron experiment. */
export const captureElectronScenario = async (
provider: ElectronActiveObservationPort | undefined,
authority: PermissionAuthority | undefined,
input: ElectronActiveObservationInput,
options: ExecutionOptions = {},
): Promise<Result<Evidence, AnalysisError>> => {
const authorized = await authorize(authority, input);
if (!authorized.ok) return authorized;
if (provider === undefined)
return err(
new AnalysisCapabilityUnavailableError(
PROVIDER_ID,
OPERATION,
"active Electron observation provider is not configured",
),
);
const captured = await provider.capture(authorized.value, options);
return captured.ok
? {
ok: true,
value: createElectronActiveEvidence(
authorized.value,
captured.value,
provider.identity(),
),
}
: captured;
};
+19 -32
View File
@@ -164,23 +164,19 @@ export class EnhancedTools {
input: unknown,
signal?: AbortSignal,
): EnhancedResult {
const parsed = enhancedInputSchemas[name].safeParse(input);
if (name === "trace_call_path") {
const parsed = enhancedInputSchemas.trace_call_path.safeParse(input);
if (!parsed.success) return invalidEnhancedInput(name, parsed.error);
return this.executeValidated({ name, input: parsed.data }, signal);
}
if (name === "trace_feature") {
const parsed = enhancedInputSchemas.trace_feature.safeParse(input);
if (!parsed.success) return invalidEnhancedInput(name, parsed.error);
return this.executeValidated({ name, input: parsed.data }, signal);
}
const parsed = enhancedInputSchemas.find_code_for_string.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,
);
return this.executeValidated({ name, input: parsed.data }, signal);
}
#executeFunctionAnalysis(
@@ -188,23 +184,14 @@ export class EnhancedTools {
input: unknown,
signal?: AbortSignal,
): EnhancedResult {
const parsed = enhancedInputSchemas[name].safeParse(input);
if (name === "analyze_function") {
const parsed = enhancedInputSchemas.analyze_function.safeParse(input);
if (!parsed.success) return invalidEnhancedInput(name, parsed.error);
return this.executeValidated({ name, input: parsed.data }, signal);
}
const parsed = enhancedInputSchemas.inspect_native_api.safeParse(input);
if (!parsed.success) return invalidEnhancedInput(name, parsed.error);
if (name === "analyze_function")
return this.executeValidated(
{
name,
input: enhancedInputSchemas.analyze_function.parse(parsed.data),
},
signal,
);
return this.executeValidated(
{
name,
input: enhancedInputSchemas.inspect_native_api.parse(parsed.data),
},
signal,
);
return this.executeValidated({ name, input: parsed.data }, signal);
}
async #analyzeFunction(
+5 -1
View File
@@ -26,7 +26,11 @@ export const importEvidenceBundleCommand = async (
const ledger = createLedger();
const imported = ledger.import(loaded.value);
return imported.ok
? ok({ imported: imported.value, total: ledger.export().records.length })
? ok({
imported: imported.value.recordsAdded,
unknowns_added: imported.value.unknownsAdded,
total: ledger.export().records.length,
})
: imported;
};
+16 -2
View File
@@ -29,7 +29,15 @@ export interface EvidenceLedgerLimits {
type EvidenceLedgerFailure = EvidenceIntegrityError | EvidenceLimitError;
type RecordResult = Result<"added" | "duplicate", EvidenceLedgerFailure>;
type ImportResult = Result<number, EvidenceLedgerFailure>;
/** Describes the state changes made by one atomic bundle import. */
export interface EvidenceImportDelta {
readonly recordsAdded: number;
readonly unknownsAdded: number;
readonly changed: boolean;
}
type ImportResult = Result<EvidenceImportDelta, EvidenceLedgerFailure>;
/** Bounded, session-owned set of immutable evidence records. */
export class EvidenceLedger {
@@ -98,6 +106,7 @@ export class EvidenceLedger {
pending.set(evidence.evidence_id, evidence);
}
const pendingUnknowns = new Map(this.#unknownRevisions);
let unknownsAdded = 0;
for (const unknown of bundle.unknowns) {
const key = unknownRevisionKey(unknown);
const existing = pendingUnknowns.get(key);
@@ -107,6 +116,7 @@ export class EvidenceLedger {
)
return err(new EvidenceIntegrityError("Conflicting unknown revision"));
pendingUnknowns.set(key, unknown);
if (existing === undefined) unknownsAdded += 1;
}
const checked = this.#validateCandidate(pending, pendingUnknowns);
if (!checked.ok) {
@@ -123,7 +133,11 @@ export class EvidenceLedger {
}
const added = pending.size - this.#records.size;
this.#commit(pending, pendingUnknowns, checked.value);
return ok(added);
return ok({
recordsAdded: added,
unknownsAdded,
changed: added > 0 || unknownsAdded > 0,
});
}
/** Export records in deterministic, semantically irrelevant ID order. */
@@ -11,7 +11,7 @@ import {
type EvidenceProvider,
type EvidenceSubjectTarget,
} from "../domain/evidence.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { err, ok, type Result } from "../domain/result.js";
interface InventoryProjectionInput {
@@ -71,7 +71,7 @@ export const projectInventoryEvidence = <
inventory_evidence_ids: [...result.source_evidence_ids],
limits: jsonValueSchema.parse(parsed.data.limits),
},
result: jsonValueSchema.parse(result) as JsonValue,
result: jsonValueSchema.parse(result),
rawResult: null,
confidence: "inferred",
authority: "analyst-inference",
@@ -1,12 +1,10 @@
import type { z } from "zod";
import type { controlledReplayInputSchema } from "../domain/javascriptReplay.js";
import type { JavaScriptReplayPolicy } from "./JavaScriptReplayPlanning.js";
import type { ControlledReplayInput } from "../domain/javascriptReplay.js";
import type { EnabledJavaScriptReplayPolicy } from "./JavaScriptReplayPlanning.js";
/** Build the exact permission request for one controlled replay operation. */
export const replayPermissionRequest = (
input: z.output<typeof controlledReplayInputSchema>,
policy: JavaScriptReplayPolicy,
input: ControlledReplayInput,
policy: EnabledJavaScriptReplayPolicy,
) => ({
capability: "javascript_replay" as const,
roots: [...input.left.modules, ...(input.right?.modules ?? [])].map(
@@ -22,5 +20,5 @@ export const replayPermissionRequest = (
environment_names: [],
network: "none" as const,
mount: true,
operation_identity: `run_controlled_replay:${input.plan_digest ?? "plan"}`,
operation_identity: `run_controlled_replay:${input.mode === "plan" ? "plan" : input.plan_digest}`,
});
+19 -13
View File
@@ -37,9 +37,10 @@ export interface ReplayRuntimeFileIdentity {
readonly sha256: string;
}
export interface JavaScriptReplayPolicy {
readonly enabled: boolean;
readonly roots: readonly string[];
/** Complete executable and source authority for enabled JavaScript replay. */
export interface EnabledJavaScriptReplayPolicy {
readonly status: "enabled";
readonly roots: readonly [string, ...string[]];
readonly nodePath: string;
readonly bubblewrapPath: string;
readonly systemdRunPath: string;
@@ -47,6 +48,11 @@ export interface JavaScriptReplayPolicy {
readonly shellPath: string;
}
/** Parsed JavaScript replay configuration. */
export type JavaScriptReplayPolicy =
| { readonly status: "disabled" }
| EnabledJavaScriptReplayPolicy;
export interface JavaScriptReplayHost {
readonly readSource: (
path: string,
@@ -61,7 +67,7 @@ export interface JavaScriptReplayHost {
nodePath: string,
) => Promise<readonly ReplayRuntimeFileIdentity[]>;
readonly seccompDigest: () => string;
readonly probe: (policy: JavaScriptReplayPolicy) => Promise<void>;
readonly probe: (policy: EnabledJavaScriptReplayPolicy) => Promise<void>;
}
export interface PreparedReplayPlan {
@@ -73,7 +79,7 @@ export interface PreparedReplayPlan {
export interface JavaScriptReplayRunner {
readonly execute: (
prepared: PreparedReplayPlan,
policy: JavaScriptReplayPolicy,
policy: EnabledJavaScriptReplayPolicy,
signal?: AbortSignal,
) => Promise<ReplayExecutionResult>;
}
@@ -81,7 +87,7 @@ export interface JavaScriptReplayRunner {
/** Read, normalize, and content-address every input to one replay experiment. */
export const prepareReplayPlan = async (
input: ControlledReplayInput,
policy: JavaScriptReplayPolicy,
policy: EnabledJavaScriptReplayPolicy,
host: JavaScriptReplayHost,
): Promise<PreparedReplayPlan> => {
const cases = replayCases(input).map((item) => ({
@@ -243,13 +249,13 @@ const replayCases = (
return state >>> 0;
};
const corpus = boundaryCorpus(input.generator.preset);
const generated = Array.from(
{ length: input.generator.count },
(_, index) => ({
case_id: `generated-${input.generator?.preset ?? "boundary"}-${String(index)}`,
arguments: [corpus[next() % corpus.length] ?? ""] as JsonValue[],
}),
);
const generated: {
readonly case_id: string;
readonly arguments: JsonValue[];
}[] = Array.from({ length: input.generator.count }, (_, index) => ({
case_id: `generated-${input.generator?.preset ?? "boundary"}-${String(index)}`,
arguments: [corpus[next() % corpus.length] ?? ""],
}));
return uniqueCases([...explicit, ...generated]);
};
+45 -35
View File
@@ -6,6 +6,8 @@ import {
replayExecutionResultSchema,
controlledReplayOutputSchema,
replayEvidenceSchema,
type ControlledReplayExecutionInput,
type ControlledReplayInput,
} from "../domain/javascriptReplay.js";
import {
AnalysisCapabilityUnavailableError,
@@ -26,6 +28,7 @@ import { replayPermissionRequest } from "./JavaScriptReplayPermission.js";
import {
digestBytes,
prepareReplayPlan,
type EnabledJavaScriptReplayPolicy,
type JavaScriptReplayHost,
type JavaScriptReplayPolicy,
type JavaScriptReplayRunner,
@@ -36,7 +39,7 @@ const isAborted = (signal: AbortSignal | undefined): boolean =>
signal?.aborted === true;
export interface JavaScriptReplayDependencies {
readonly policy: JavaScriptReplayPolicy;
readonly policy: () => JavaScriptReplayPolicy;
readonly host: JavaScriptReplayHost;
readonly runner: JavaScriptReplayRunner;
readonly authority: PermissionAuthority | undefined;
@@ -63,14 +66,20 @@ export const runControlledReplay = async (
/** Plan or execute replay input already parsed by a trusted adapter. */
export const runControlledReplayValidated = async (
dependencies: JavaScriptReplayDependencies,
input: z.output<typeof controlledReplayInputSchema>,
input: ControlledReplayInput,
options: ExecutionOptions = {},
): Promise<Result<JsonValue, AnalysisError>> => {
const authorized = await authorizeReplay(dependencies, input, options.signal);
const authorized = await authorizeReplay(
dependencies,
dependencies.policy(),
input,
options.signal,
);
if (!authorized.ok) return authorized;
const prepared = await prepareValidatedReplay(
dependencies,
authorized.value,
input,
options.signal,
);
@@ -91,7 +100,7 @@ export const runControlledReplayValidated = async (
if (input.plan_digest !== prepared.value.publicPlan.plan_digest)
return err(
new ReplayPlanStaleError(
input.plan_digest ?? "",
input.plan_digest,
prepared.value.publicPlan.plan_digest,
),
);
@@ -103,12 +112,13 @@ export const runControlledReplayValidated = async (
);
if (!exportContext.ok) return exportContext;
const executed = await executeValidatedReplay(
dependencies,
prepared.value,
exportContext.value,
const executed = await executeValidatedReplay({
runner: dependencies.runner,
policy: authorized.value,
prepared: prepared.value,
exportContext: exportContext.value,
options,
);
});
if (!executed.ok) return executed;
try {
@@ -133,10 +143,11 @@ export const runControlledReplayValidated = async (
const authorizeReplay = async (
dependencies: JavaScriptReplayDependencies,
input: z.output<typeof controlledReplayInputSchema>,
policy: JavaScriptReplayPolicy,
input: ControlledReplayInput,
signal: AbortSignal | undefined,
): Promise<Result<true, AnalysisError>> => {
if (!dependencies.policy.enabled)
): Promise<Result<EnabledJavaScriptReplayPolicy, AnalysisError>> => {
if (policy.status === "disabled")
return err(
new AnalysisCapabilityUnavailableError(
"rea-javascript-replay",
@@ -154,7 +165,7 @@ const authorizeReplay = async (
);
if (isAborted(signal)) return err(new AnalysisCancelledError(OPERATION));
const request = replayPermissionRequest(input, dependencies.policy);
const request = replayPermissionRequest(input, policy);
const authorized =
input.mode === "plan"
? await dependencies.authority.explain(request, "read")
@@ -167,21 +178,20 @@ const authorizeReplay = async (
cause: authorized.error,
}),
);
return ok(true);
return ok(policy);
};
const prepareValidatedReplay = async (
dependencies: JavaScriptReplayDependencies,
input: z.output<typeof controlledReplayInputSchema>,
policy: EnabledJavaScriptReplayPolicy,
input: ControlledReplayInput,
signal: AbortSignal | undefined,
): Promise<
Result<Awaited<ReturnType<typeof prepareReplayPlan>>, AnalysisError>
> => {
if (isAborted(signal)) return err(new AnalysisCancelledError(OPERATION));
try {
return ok(
await prepareReplayPlan(input, dependencies.policy, dependencies.host),
);
return ok(await prepareReplayPlan(input, policy, dependencies.host));
} catch (cause: unknown) {
return err(
new AnalysisCapabilityUnavailableError(
@@ -200,12 +210,10 @@ interface ReproducerExportContext {
const authorizeReproducerExport = async (
authority: PermissionAuthority | undefined,
input: z.output<typeof controlledReplayInputSchema>,
input: ControlledReplayExecutionInput,
prepared: Awaited<ReturnType<typeof prepareReplayPlan>>,
): Promise<Result<ReproducerExportContext | undefined, AnalysisError>> => {
if (input.reproducer_export === undefined) return ok(undefined);
if (input.reproducer_export.approved !== true)
return err(new AnalysisInputError(`${OPERATION}:reproducer_export`));
if (authority === undefined)
return err(
new AnalysisCapabilityUnavailableError(
@@ -249,11 +257,16 @@ const authorizeReproducerExport = async (
});
};
interface ReplayExecutionContext {
readonly runner: JavaScriptReplayRunner;
readonly policy: EnabledJavaScriptReplayPolicy;
readonly prepared: Awaited<ReturnType<typeof prepareReplayPlan>>;
readonly exportContext: ReproducerExportContext | undefined;
readonly options: ExecutionOptions;
}
const executeValidatedReplay = async (
dependencies: JavaScriptReplayDependencies,
prepared: Awaited<ReturnType<typeof prepareReplayPlan>>,
exportContext: ReproducerExportContext | undefined,
options: ExecutionOptions,
context: ReplayExecutionContext,
): Promise<
Result<
{
@@ -263,6 +276,7 @@ const executeValidatedReplay = async (
AnalysisError
>
> => {
const { runner, policy, prepared, exportContext, options } = context;
await options.progress?.report({
phase: OPERATION,
completed: 0,
@@ -274,11 +288,7 @@ const executeValidatedReplay = async (
try {
let executed = replayExecutionResultSchema.parse(
await dependencies.runner.execute(
prepared,
dependencies.policy,
options.signal,
),
await runner.execute(prepared, policy, options.signal),
);
await options.progress?.report({
phase: OPERATION,
@@ -505,9 +515,9 @@ const createReplayEvidence = (
);
};
const asJson = (value: unknown): JsonValue =>
jsonValueSchema.parse(
JSON.parse(
JSON.stringify(controlledReplayOutputSchema.parse(value)),
) as unknown,
const asJson = (value: unknown): JsonValue => {
const serialized: unknown = JSON.parse(
JSON.stringify(controlledReplayOutputSchema.parse(value)),
);
return jsonValueSchema.parse(serialized);
};
@@ -23,14 +23,23 @@ import { digestJson } from "./JavaScriptReplayPlanning.js";
const OPERATION = "plan_managed_runtime_correlation" as const;
export interface ManagedRuntimePolicy {
readonly enabled: boolean;
readonly roots: readonly string[];
readonly executablePath: string;
}
/** Parsed availability and authority scope for managed runtime correlation. */
export type ManagedRuntimePolicy =
| { readonly status: "disabled" }
| {
readonly status: "enabled";
readonly roots: readonly [string, ...string[]];
readonly executablePath: string;
};
/** A managed-runtime policy that grants no runtime-correlation authority. */
export const MANAGED_RUNTIME_DISABLED = {
status: "disabled",
} as const satisfies ManagedRuntimePolicy;
/** Live policy and authority dependencies for managed runtime correlation. */
export interface ManagedRuntimeCorrelationDependencies {
readonly policy: ManagedRuntimePolicy;
readonly policy: () => ManagedRuntimePolicy;
readonly authority: PermissionAuthority | undefined;
}
@@ -53,7 +62,8 @@ export const planManagedRuntimeCorrelationEvidenceValidated = async (
dependencies: ManagedRuntimeCorrelationDependencies,
input: ManagedRuntimeCorrelationInput,
): Promise<Result<Evidence, AnalysisError>> => {
if (!dependencies.policy.enabled)
const policy = dependencies.policy();
if (policy.status === "disabled")
return err(
new AnalysisCapabilityUnavailableError(
MANAGED_WORKFLOW_PROVIDER.id,
@@ -69,8 +79,7 @@ export const planManagedRuntimeCorrelationEvidenceValidated = async (
"managed runtime permission policy is not configured",
),
);
let staticEvidence: Evidence;
let artifactPath: string;
let staticEvidence: Evidence, artifactPath: string;
try {
staticEvidence = parseEvidence(input.static_members);
artifactPath = artifactPathFor(staticEvidence);
@@ -81,7 +90,7 @@ export const planManagedRuntimeCorrelationEvidenceValidated = async (
{
capability: "managed_runtime",
roots: [artifactPath],
executables: [dependencies.policy.executablePath],
executables: [policy.executablePath],
environment_names: [],
network: "none",
mount: false,
@@ -101,7 +110,7 @@ export const planManagedRuntimeCorrelationEvidenceValidated = async (
try {
const result = planManagedRuntimeCorrelation(
input,
dependencies.policy.executablePath,
policy.executablePath,
authorized.value.grant_id,
);
return ok(
@@ -16,7 +16,10 @@ import {
type AnalysisError,
} from "../domain/errors.js";
import { createEvidence, type Evidence } from "../domain/evidence.js";
import { controlledReplayOutputSchema } from "../domain/javascriptReplay.js";
import {
controlledReplayOutputSchema,
type ControlledReplayExecutionOutput,
} from "../domain/javascriptReplay.js";
import type { ReplayPlan } from "../domain/javascriptReplay.js";
import type { JavaScriptExportInstrumentation } from "../domain/javascriptExportInstrumentation.js";
import {
@@ -89,7 +92,7 @@ export const executeNodeCharacterization = async (
const plannedReplay = controlledReplayOutputSchema.parse(
prepared.value.replay,
);
if (plannedReplay.phase !== "plan" || plannedReplay.plan === null)
if (plannedReplay.phase !== "plan")
return err(
new AnalysisProtocolError(
"Node characterization preparation did not produce a replay plan",
@@ -110,6 +113,13 @@ export const executeNodeCharacterization = async (
options,
);
if (!executed.ok) return executed;
const replayOutput = controlledReplayOutputSchema.parse(executed.value);
if (replayOutput.phase !== "execute")
return err(
new AnalysisProtocolError(
"Node characterization execution omitted replay Evidence",
),
);
if (host.instrumentation === null)
return err(
new AnalysisProtocolError(
@@ -124,11 +134,8 @@ export const executeNodeCharacterization = async (
plan: prepared.value.plan,
transformation: host.instrumentation.manifest,
transformation_evidence: prepared.value.transformation_evidence,
evidence: createCharacterizationEvidence(
prepared.value,
controlledReplayOutputSchema.parse(executed.value),
),
replay: controlledReplayOutputSchema.parse(executed.value),
evidence: createCharacterizationEvidence(prepared.value, replayOutput),
replay: replayOutput,
}),
),
);
@@ -164,11 +171,7 @@ const prepareValidated = async (
);
if (!replay.ok) return replay;
const output = controlledReplayOutputSchema.parse(replay.value);
if (
output.phase !== "plan" ||
output.plan === null ||
host.instrumentation === null
)
if (output.phase !== "plan" || host.instrumentation === null)
return err(
new AnalysisProtocolError(
"Node characterization preparation omitted committed plan evidence",
@@ -240,12 +243,8 @@ const createCharacterizationEvidence = (
readonly plan: RuntimeCharacterizationPlan;
readonly transformation_evidence: Evidence;
},
replay: z.output<typeof controlledReplayOutputSchema>,
replay: ControlledReplayExecutionOutput,
): Evidence => {
if (replay.phase !== "execute" || replay.evidence === null)
throw new AnalysisProtocolError(
"Node characterization execution omitted replay Evidence",
);
return createEvidence(
{
path: prepared.plan.artifact.path,
+2 -2
View File
@@ -1,7 +1,7 @@
import { realpath } from "node:fs/promises";
import type {
ProcessExecutionPolicy,
EnabledProcessExecutionPolicy,
ProcessScenario,
} from "../domain/processCapture.js";
import {
@@ -16,7 +16,7 @@ const isWithin = (candidate: string, root: string): boolean =>
export const assertRealPathAuthority = async (
scenario: ProcessScenario,
policy: ProcessExecutionPolicy,
policy: EnabledProcessExecutionPolicy,
): Promise<void> => {
const executable = await realpath(scenario.executable);
const executableRoots = await canonicalizeConfiguredRoots(
@@ -420,6 +420,11 @@ export const prepareProcessCapture = async (
readonly home: string;
readonly before: SnapshotResult;
}> => {
if (policy.status === "disabled")
throw new ProcessCaptureError("process capture is disabled", {
userCategory: "permission_required",
userMessage: processPolicyMessage("process capture is disabled"),
});
const decision = authorizeProcessScenario(scenario, policy);
if (!decision.allowed)
throw new ProcessCaptureError(decision.reason, {
+2 -1
View File
@@ -187,7 +187,8 @@ const readJson = async (path: string): Promise<unknown> => {
"Process input file is too large. Reduce it below 64 MiB, then try again.",
);
try {
return JSON.parse(bytes.toString("utf8")) as unknown;
const parsed: unknown = JSON.parse(bytes.toString("utf8"));
return parsed;
} catch {
throw new ProcessCliFailure(
"invalid_input",
+9 -4
View File
@@ -333,10 +333,6 @@ const completeCapture = async (options: {
}
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),
@@ -345,6 +341,15 @@ const completeCapture = async (options: {
);
runtime.checkpoints.trigger("settled");
const samplingPartial = (await runtime.stopSampler()).partial;
if (runtime.reactive !== undefined) {
// Process, filesystem, shim, and protocol observations can be recorded
// during settlement after the PTY exits. Make target loss terminal only
// after that journal has drained, otherwise the coordinator's control
// priority discards a valid multi-source completion predicate.
await runtime.reactive.coordinator.drain();
await runtime.reactive.coordinator.submit({ kind: "target_lost" });
runtime.reactive.unsubscribe();
}
assertNotCancelled(options.signal);
const after = await snapshotRoots(scenario, options.signal);
const renderedFrames = await runtime.renderer.frames();
+2 -1
View File
@@ -71,9 +71,10 @@ export const normalizeProcessSamples = (
"<no-temporary-root>",
rootPid,
);
return scenario.normalization.pids
const withNormalizedPids = scenario.normalization.pids
? normalizePidTokens(normalized, identifiers)
: normalized;
return withNormalizedPids.trim();
};
return samples.map((sample) => ({
at_ms: normalizeProcessElapsedTime(
@@ -25,6 +25,7 @@ const grantSchema = z.object({
"browser_observe",
"browser_automate",
"electron_observe",
"electron_automate",
"evidence_read",
"evidence_write",
"investigation_input",
+2 -2
View File
@@ -260,11 +260,11 @@ export const importReferenceSource = async (
try {
return ok(createHistoricalSourceGraph(input));
} catch (error) {
} catch (cause: unknown) {
return err(
failure(
"parse",
error instanceof Error ? error.message : "Graph failed validation",
cause instanceof Error ? cause.message : "Graph failed validation",
),
);
}
+6 -2
View File
@@ -1,6 +1,7 @@
import { constants as fsConstants } from "node:fs";
import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import canonicalize from "canonicalize";
import { z } from "zod";
import writeFileAtomic from "write-file-atomic";
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
@@ -257,8 +258,11 @@ const parseObject = (text: string): Record<string, unknown> =>
objectSchema.parse(JSON.parse(text));
const isMissing = (cause: unknown): boolean =>
cause instanceof Error && "code" in cause && cause.code === "ENOENT";
const sameConfiguration = (left: unknown, right: unknown): boolean =>
JSON.stringify(left) === JSON.stringify(right);
const sameConfiguration = (left: unknown, right: unknown): boolean => {
const encodedLeft = canonicalize(left);
const encodedRight = canonicalize(right);
return encodedLeft !== undefined && encodedLeft === encodedRight;
};
const preserveConfigBackup = async (
source: string,
+13 -8
View File
@@ -3,16 +3,19 @@ import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
windowsNativeCapabilities,
type WindowsNativeCapabilitySet,
} from "../process/WindowsAuthority.js";
import {
probeProcessCaptureCapability,
type ProcessCaptureCapability,
} from "./ProcessCaptureCapability.js";
/** One named Windows host observation without inferred authority. */
interface WindowsCapabilityOutcome {
readonly available: boolean;
readonly reason: string | null;
}
export type WindowsCapabilityOutcome =
| { readonly available: true; readonly reason: null }
| { readonly available: false; readonly reason: string };
/** Injectable host probes used by CI and deterministic unit tests. */
export interface WindowsCapabilityDependencies {
@@ -36,6 +39,8 @@ export interface WindowsCapabilityReport {
unix_domain_socket: WindowsCapabilityOutcome;
pty: WindowsCapabilityOutcome;
}>;
/** Security controls that are unavailable until native authority exists. */
readonly security: WindowsNativeCapabilitySet;
}
/** Probe named prerequisites without converting missing controls into support. */
@@ -54,11 +59,11 @@ export const probeWindowsCapabilities = async (
no_follow_open: dependencies.probeNoFollowOpen(),
private_acl: dependencies.probePrivateAcl(),
unix_domain_socket: dependencies.probeUnixDomainSocket(),
pty: {
available: pty.available,
reason: pty.available ? null : pty.reason,
},
pty: pty.available
? { available: true, reason: null }
: { available: false, reason: pty.reason },
},
security: windowsNativeCapabilities(dependencies.platform),
};
};
+17 -10
View File
@@ -31,6 +31,10 @@ import {
import { ARTIFACT_GRAPH_PROVIDER } from "../application/InvestigationProviders.js";
import { createEvidence } from "../domain/evidence.js";
import { createArtifactInspection } from "../domain/artifactInspection.js";
import {
resolveArtifactIntegrityPolicy,
resolveNativeMountPolicy,
} from "../application/ArtifactInventory/policy.js";
const IDENTITY: ProviderIdentity = Object.freeze(ARTIFACT_GRAPH_PROVIDER);
@@ -276,16 +280,19 @@ class ArtifactClient implements AnalysisClient {
},
{
...(options?.signal === undefined ? {} : { signal: options.signal }),
nativeMount: {
nativeMountApproved: parsed.native_mount_approved === true,
nativeMountEnabled: this.nativeMountEnabled,
},
integrity: {
mode: parsed.integrity_policy,
approved: parsed.integrity_continue_approved === true,
enabled: this.integrityContinueEnabled,
maxMismatches: parsed.max_integrity_mismatches,
},
nativeMount: resolveNativeMountPolicy(
parsed.native_mount_approved === true,
this.nativeMountEnabled,
),
integrity: resolveArtifactIntegrityPolicy(
parsed.integrity_policy === "fail"
? { mode: "fail" }
: {
mode: parsed.integrity_policy,
maxMismatches: parsed.max_integrity_mismatches,
},
this.integrityContinueEnabled,
),
},
);
}
+15 -14
View File
@@ -21,12 +21,14 @@ export interface SafeOutputFile {
readonly bytesWritten: number;
}
/** Cleanup facts established while rolling back an uncommitted tree. */
export interface SafeOutputCleanup {
readonly attempted: boolean;
readonly verified: boolean;
readonly residualPaths: readonly string[];
}
/** Cleanup state established while rolling back an uncommitted tree. */
export type SafeOutputCleanup =
| { readonly status: "not-required" }
| { readonly status: "complete"; readonly residualPaths: readonly [] }
| {
readonly status: "incomplete";
readonly residualPaths: readonly [string, ...string[]];
};
/** Symlink-resistant materialization in an exclusively owned, initially absent tree. */
export class SafeOutputTree {
@@ -37,9 +39,7 @@ export class SafeOutputTree {
#totalBytes = 0;
#published = false;
#cleanup: SafeOutputCleanup = {
attempted: false,
verified: false,
residualPaths: [],
status: "not-required",
};
private constructor(
@@ -208,11 +208,12 @@ export class SafeOutputTree {
if (this.#published) return structuredClone(this.#cleanup);
await rm(this.#stagingRoot, { recursive: true, force: true });
const absent = await isAbsent(this.#stagingRoot);
this.#cleanup = {
attempted: true,
verified: absent,
residualPaths: absent ? [] : [basename(this.#stagingRoot)],
};
this.#cleanup = absent
? { status: "complete", residualPaths: [] }
: {
status: "incomplete",
residualPaths: [basename(this.#stagingRoot)],
};
if (!absent)
throw new ArtifactReaderFailure(
"integrity",
+1 -1
View File
@@ -75,7 +75,7 @@ export const captureStorage = async (
caches: caches.slice(0, maximum),
limitations,
})
: { items: [], complete: false, truncated: false };
: { items: [], complete: false as const, truncated: false };
const truncated =
[local.items, session.items, indexed, caches].some(
(items) => items.length > maximum,
+19 -13
View File
@@ -58,6 +58,13 @@ interface CaptureStorageFingerprintInput {
readonly limitations: string[];
}
type StorageFingerprintCapture = {
readonly items: readonly StorageFingerprint[];
} & (
| { readonly complete: true; readonly truncated: false }
| { readonly complete: false; readonly truncated: boolean }
);
/** Capture stable hashes of approved storage content without returning values. */
export const captureStorageFingerprints = async ({
context,
@@ -67,11 +74,7 @@ export const captureStorageFingerprints = async ({
indexedDbNames,
caches,
limitations,
}: CaptureStorageFingerprintInput): Promise<{
readonly items: readonly StorageFingerprint[];
readonly complete: boolean;
readonly truncated: boolean;
}> => {
}: CaptureStorageFingerprintInput): Promise<StorageFingerprintCapture> => {
const state: FingerprintState = {
items: [],
maximum: context.input.limits.max_storage_keys,
@@ -90,14 +93,17 @@ export const captureStorageFingerprints = async ({
`${right.scope}:${right.identity_sha256}`,
),
);
return {
items: state.items,
complete:
state.complete &&
!state.truncated &&
state.items.every(({ complete }) => complete),
truncated: state.truncated,
};
const complete =
state.complete &&
!state.truncated &&
state.items.every(({ complete }) => complete);
return complete
? { items: state.items, complete: true, truncated: false }
: {
items: state.items,
complete: false,
truncated: state.truncated,
};
};
const addStorageItems = (
+3 -1
View File
@@ -99,7 +99,9 @@ const runElectronInspection = async (
startedAt: string,
): Promise<ElectronPageInspection> => {
const frames = await authorizeInitialFrames(context, state);
const main = frames[0]!;
const main = frames.at(0);
if (main === undefined)
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
state.mainFrameId = main.frame_id;
await enableElectronCapture(context);
await delayWithCancellation(
+11
View File
@@ -58,3 +58,14 @@ export const authorizeRuntimeLocation = async (
},
};
};
/** Authorize a target whose Node Inspector URL omits its main entry path. */
export const authorizeRuntimeTargetLocation = async (
value: string,
targetType: string,
roots: readonly string[],
origins: readonly string[],
): Promise<RuntimeLocationDecision> =>
// A bare file:// URL contains no source identity. Do not attach to the
// first configured root merely because the target happens to be Node.
authorizeRuntimeLocation(value, roots, origins);
@@ -218,7 +218,7 @@ const executeStep = async (input: {
const remaining = scenario.limits.max_duration_ms - elapsed;
if (remaining <= 0) throw new Error("Scenario duration limit reached");
await session.perform(action, remaining, signal);
} catch (cause) {
} catch (cause: unknown) {
status = requestCancelled() ? "cancelled" : "failed";
error = session.redactError(cause);
}
@@ -376,7 +376,7 @@ export class PlaywrightBrowserScenarioProvider
): Promise<Result<BrowserScenarioCapture, AnalysisError>> {
try {
return ok(await runScenario(this.factory, scenario, options));
} catch (cause) {
} catch (cause: unknown) {
if (cause instanceof AnalysisError) return err(cause);
return err(
new ProviderAdapterError(
@@ -0,0 +1,489 @@
import type { ElectronApplication, Page } from "playwright-core";
import { z } from "zod";
import type { ExecutionOptions } from "../application/AnalysisProvider.js";
import { BrowserObservationError } from "../domain/errors.js";
import { redactSensitiveText } from "./SensitiveTextCapture.js";
import type {
ElectronActiveObservationInput,
ElectronActiveObservationResult,
} from "../domain/electronActiveObservation.js";
const OPERATION = "capture_electron_scenario" as const;
const hookEventSchema = z.strictObject({
sequence: z.number().int().min(1),
correlation_id: z.string().max(256).nullable(),
kind: z.enum([
"main-handler-invocation",
"main-event-invocation",
"utility-process-fork",
"utility-process-message",
"ipc-main-to-renderer",
"ipc-utility-to-main",
"ipc-renderer-send",
"ipc-renderer-invoke",
"ipc-renderer-post-message",
"app-lifecycle",
"window-lifecycle",
"web-contents-lifecycle",
"navigation",
"shell-attempt",
"process-lifecycle",
"permission",
"popup-attempt",
"download",
"protocol",
"preload",
"native-addon",
"updater",
"error",
]),
event: z.string().max(128).nullable(),
phase: z.enum(["attempted", "completed", "blocked", "failed", "observed"]),
channel: z.string().max(1_024).nullable(),
channel_truncated: z.boolean().default(false),
direction: z
.enum([
"renderer-to-main",
"main-to-renderer",
"main-to-utility",
"utility-to-main",
])
.nullable(),
sender: z.string().max(256).nullable(),
receiver: z.string().max(256).nullable(),
frame: z.string().max(256).nullable(),
target: z.string().max(256).nullable(),
argument_shapes: z.array(z.string().max(64)).max(32),
argument_shapes_truncated: z.boolean().default(false),
result_shape: z.string().max(64).nullable(),
process_type: z.string().max(128).nullable(),
source: z.string().max(64),
capture_method: z.enum(["api-wrapper", "event-emitter", "process-hook"]),
artifact_path: z.string().max(16_384).nullable(),
artifact_sha256: z
.string()
.regex(/^[a-f0-9]{64}$/u)
.nullable(),
error: z.boolean(),
});
const hookSnapshotSchema = z.strictObject({
events: z.array(hookEventSchema).max(20_000),
observed: z.number().int().min(0),
observed_ipc: z.number().int().min(0),
observed_runtime: z.number().int().min(0),
truncated: z.boolean(),
hook_error: z.boolean(),
});
const metricSchema = z.strictObject({
pid: z.number().int().min(1),
type: z.string().min(1).max(128),
name: z.string().max(256).nullable(),
service_name: z.string().max(256).nullable(),
});
const windowMetadataSchema = z.strictObject({
window_id: z.string().max(256),
web_contents_id: z.string().max(256),
visible: z.boolean().nullable(),
destroyed: z.boolean(),
});
type ElectronAction = ElectronActiveObservationInput["actions"][number];
type ElectronActions = ElectronActiveObservationResult["actions"];
export type ElectronHookSnapshot = z.infer<typeof hookSnapshotSchema>;
export type ElectronHookEvent = z.infer<typeof hookEventSchema>;
export type ElectronMetrics = z.infer<typeof metricSchema>[];
type ElectronWindowMetadata = z.infer<typeof windowMetadataSchema>;
type ElectronPageSnapshot = {
readonly index: number;
readonly url: string;
readonly title: string;
};
/** Run bounded, explicit actions against provider-owned Electron windows. */
export const runElectronActions = async (
application: ElectronApplication,
input: ElectronActiveObservationInput,
options: ExecutionOptions,
deadline: number,
): Promise<ElectronActions> => {
const actions: ElectronActions = [];
for (const action of input.actions) {
if (options.signal?.aborted === true)
throw new BrowserObservationError(OPERATION, "cancelled");
const actionStartedAt = Date.now();
const windowIndex = actionWindowIndex(action);
let selectedWindow: Page | undefined;
try {
const remaining = deadline - Date.now();
if (remaining <= 0)
throw new BrowserObservationError(OPERATION, "timeout");
selectedWindow =
windowIndex === null
? undefined
: await waitForWindow({
application,
windowIndex,
timeout: input.limits.action_timeout_ms,
signal: options.signal,
deadline,
});
if (
(action.kind === "click" ||
action.kind === "renderer-reload" ||
action.kind === "renderer-crash") &&
selectedWindow === undefined
)
throw new BrowserObservationError(OPERATION, "window_not_found");
await runAction({
application,
action,
selectedWindow,
remaining,
actionTimeoutMs: input.limits.action_timeout_ms,
options,
deadline,
});
actions.push({
step_id: action.step_id,
kind: action.kind,
window_index: windowIndex,
target:
selectedWindow === undefined ? null : `window:${String(windowIndex)}`,
status: "completed",
elapsed_ms: Date.now() - actionStartedAt,
error: null,
});
} catch (cause: unknown) {
actions.push({
step_id: action.step_id,
kind: action.kind,
window_index: windowIndex,
target:
selectedWindow === undefined ? null : `window:${String(windowIndex)}`,
status: options.signal?.aborted ? "cancelled" : "failed",
elapsed_ms: Date.now() - actionStartedAt,
error: safeActionErrorMessage(cause, action),
});
break;
}
}
return actions;
};
type RunActionContext = {
readonly application: ElectronApplication;
readonly action: ElectronAction;
readonly selectedWindow: Page | undefined;
readonly remaining: number;
readonly actionTimeoutMs: number;
readonly options: ExecutionOptions;
readonly deadline: number;
};
const runAction = async ({
application,
action,
selectedWindow,
remaining,
actionTimeoutMs,
options,
deadline,
}: RunActionContext): Promise<void> => {
switch (action.kind) {
case "click":
if (selectedWindow === undefined)
throw new BrowserObservationError(OPERATION, "window_not_found");
await runWithExecutionLimits(
selectedWindow.locator(action.selector).click({
timeout: Math.min(actionTimeoutMs, remaining),
}),
options.signal,
deadline,
);
return;
case "wait": {
const page =
selectedWindow ??
(await application.firstWindow({
timeout: Math.min(actionTimeoutMs, remaining),
}));
await runWithExecutionLimits(
page.waitForTimeout(Math.min(action.duration_ms, remaining)),
options.signal,
deadline,
);
if (action.duration_ms > remaining)
throw new BrowserObservationError(OPERATION, "timeout");
return;
}
case "renderer-reload":
if (selectedWindow === undefined)
throw new BrowserObservationError(OPERATION, "window_not_found");
await runWithExecutionLimits(
selectedWindow.reload({
timeout: Math.min(actionTimeoutMs, remaining),
}),
options.signal,
deadline,
);
return;
case "renderer-crash": {
const crashed = await runWithExecutionLimits(
application.evaluate(({ BrowserWindow }, index) => {
const candidate = BrowserWindow.getAllWindows()[index];
if (candidate === undefined || candidate.isDestroyed()) return false;
candidate.webContents.forcefullyCrashRenderer();
return true;
}, action.window_index),
options.signal,
deadline,
);
if (!crashed)
throw new BrowserObservationError(OPERATION, "window_not_found");
return;
}
case "deep-link":
await runWithExecutionLimits(
emitDeepLink(application, action),
options.signal,
deadline,
);
return;
}
};
const actionWindowIndex = (action: ElectronAction): number | null => {
switch (action.kind) {
case "click":
case "renderer-reload":
case "renderer-crash":
return action.window_index;
case "wait":
return action.window_index ?? null;
case "deep-link":
return null;
}
};
type WaitForWindowContext = {
readonly application: ElectronApplication;
readonly windowIndex: number;
readonly timeout: number;
readonly signal: AbortSignal | undefined;
readonly deadline: number;
};
const waitForWindow = async ({
application,
windowIndex,
timeout,
signal,
deadline,
}: WaitForWindowContext): Promise<Page> => {
const stopAt = Math.min(deadline, Date.now() + timeout);
while (Date.now() < stopAt) {
if (signal?.aborted === true)
throw new BrowserObservationError(OPERATION, "cancelled");
const window = application.windows()[windowIndex];
if (window !== undefined) return window;
await runWithExecutionLimits(
new Promise<void>((resolve) => setTimeout(resolve, 25)),
signal,
stopAt,
);
}
throw new BrowserObservationError(OPERATION, "window_not_found");
};
const emitDeepLink = (
application: ElectronApplication,
action: Extract<ElectronAction, { kind: "deep-link" }>,
): Promise<boolean> =>
application.evaluate(
({ app }, payload) => {
if (payload.delivery === "open-url")
return app.emit(
"open-url",
{ preventDefault: () => undefined },
payload.url,
);
return app.emit("second-instance", {}, [payload.url], "", {});
},
{ delivery: action.delivery, url: action.url },
);
/** Read the current Electron windows, metrics, and bounded hook snapshot. */
// oxlint-disable-next-line max-lines-per-function -- one state read must share one capture boundary.
export const readApplicationState = async (
application: ElectronApplication,
maxWindows: number,
): Promise<{
readonly windows: ReadonlyArray<{
readonly window_id: string;
readonly web_contents_id: string;
readonly url: string;
readonly title: string;
readonly visible: boolean | null;
readonly destroyed: boolean;
}>;
readonly windowsTruncated: boolean;
readonly metrics: ElectronMetrics;
readonly electronVersion: string;
readonly hookSnapshot: ElectronHookSnapshot;
}> => {
const pages = application.windows();
const pageSnapshotsPromise: Promise<ElectronPageSnapshot[]> = Promise.all(
pages.slice(0, maxWindows).map(
async (page: Page, index): Promise<ElectronPageSnapshot> => ({
index,
url: page.url().slice(0, 65_536),
title: (await page.title()).slice(0, 16_384),
}),
),
);
const windowMetadataPromise: Promise<ElectronWindowMetadata[]> = application
.evaluate(({ BrowserWindow }) =>
BrowserWindow.getAllWindows().map(
(window: {
readonly id: number;
readonly webContents: { readonly id: number };
isDestroyed(): boolean;
isVisible(): boolean;
}) => ({
window_id: `window:${String(window.id)}`,
web_contents_id: `webContents:${String(window.webContents.id)}`,
visible: window.isDestroyed() ? null : window.isVisible(),
destroyed: window.isDestroyed(),
}),
),
)
.then((value) => z.array(windowMetadataSchema).parse(value));
const processMetricsPromise: Promise<ElectronMetrics> = application
.evaluate(({ app }) =>
app
.getAppMetrics()
.map(
(metric: {
readonly pid: number;
readonly type: string;
readonly name?: string;
readonly serviceName?: string;
}) => ({
pid: metric.pid,
type: metric.type,
name: metric.name ?? null,
service_name: metric.serviceName ?? null,
}),
),
)
.then((value) => z.array(metricSchema).parse(value));
const electronVersionPromise: Promise<string> = application
.evaluate(() => process.versions.electron)
.then((value) => z.string().parse(value));
const hookSnapshotPromise: Promise<ElectronHookSnapshot> = application
.evaluate(() => {
const candidate = Reflect.get(globalThis, "__reaElectronActiveSnapshot");
return typeof candidate === "function"
? candidate()
: {
events: [],
observed: 0,
observed_ipc: 0,
observed_runtime: 0,
truncated: false,
hook_error: true,
};
})
.then((value) => hookSnapshotSchema.parse(value));
const [
pageSnapshots,
windowMetadata,
processMetrics,
electronVersion,
hookSnapshot,
] = await Promise.all([
pageSnapshotsPromise,
windowMetadataPromise,
processMetricsPromise,
electronVersionPromise,
hookSnapshotPromise,
]);
return {
windows: pageSnapshots.map((page) => {
const metadata = windowMetadata[page.index];
if (metadata === undefined)
throw new BrowserObservationError(OPERATION, "window_metadata_missing");
return {
window_id: metadata.window_id,
web_contents_id: metadata.web_contents_id,
url: page.url,
title: page.title,
visible: metadata.visible,
destroyed: metadata.destroyed,
};
}),
windowsTruncated: pages.length > maxWindows,
metrics: processMetrics,
electronVersion,
hookSnapshot,
};
};
/** Apply a deadline and caller cancellation to one Playwright operation. */
export const runWithExecutionLimits = async <Value>(
operation: Promise<Value>,
signal: AbortSignal | undefined,
deadline: number,
): Promise<Value> => {
if (signal?.aborted === true)
throw new BrowserObservationError(OPERATION, "cancelled");
let timer: ReturnType<typeof setTimeout> | undefined;
let abortListener: (() => void) | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new BrowserObservationError(OPERATION, "timeout")),
Math.max(1, deadline - Date.now()),
);
});
const cancellation =
signal === undefined
? timeout
: Promise.race([
timeout,
new Promise<never>((_, reject) => {
abortListener = () =>
reject(new BrowserObservationError(OPERATION, "cancelled"));
signal.addEventListener("abort", abortListener, { once: true });
}),
]);
try {
return await Promise.race([operation, cancellation]);
} finally {
if (timer !== undefined) clearTimeout(timer);
if (abortListener !== undefined)
signal?.removeEventListener("abort", abortListener);
}
};
const safeActionErrorMessage = (
cause: unknown,
action: ElectronAction,
): string => {
let message =
cause instanceof Error ? cause.message : "Electron action failed";
const inputValues = Object.values(action)
.filter(
(value): value is string => typeof value === "string" && value.length > 0,
)
.sort((left, right) => right.length - left.length);
for (const value of inputValues)
message = message.split(value).join("<redacted-input>");
return redactSensitiveText(message).slice(0, 1_024);
};
@@ -0,0 +1,432 @@
import { realpath } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import { fileURLToPath } from "node:url";
import { _electron as electron } from "playwright-core";
import type {
ExecutionOptions,
ProviderIdentity,
} from "../application/AnalysisProvider.js";
import type { ElectronActiveObservationPort } from "../application/ElectronActiveObservationPort.js";
import {
electronActiveObservationResultSchema,
type ElectronActiveObservationInput,
type ElectronActiveObservationResult,
} from "../domain/electronActiveObservation.js";
import {
AnalysisError,
BrowserObservationError,
ProviderAdapterError,
} from "../domain/errors.js";
import { isPathContained } from "../domain/permissionPolicy.js";
import { err, ok, type Result } from "../domain/result.js";
import {
cleanupOwnedProcessGroup,
cleanupWindowsProcessTree,
observeOwnedProcessLineage,
selectCapturedProcessGroupIds,
verifyNoTokenOwnedProcesses,
type OwnedProcessGroup,
type ProcessCleanupResult,
type ProcessLineageObservation,
} from "../process/ProcessOwnership.js";
import {
runElectronActions,
readApplicationState,
runWithExecutionLimits,
type ElectronHookEvent,
type ElectronHookSnapshot,
type ElectronMetrics,
} from "./PlaywrightElectronActiveActions.js";
const OPERATION = "capture_electron_scenario" as const;
/** Public identity for provider-owned Electron runtime experiments. */
export const PLAYWRIGHT_ELECTRON_ACTIVE_PROVIDER_IDENTITY: ProviderIdentity =
Object.freeze({
id: "rea-playwright-electron-active",
name: "REA Playwright active Electron observation provider",
version: "1",
});
const hookPath = fileURLToPath(
new URL("../../scripts/electron-active-hook.cjs", import.meta.url),
);
type ElectronApplication = Awaited<ReturnType<typeof electron.launch>>;
type ElectronPaths = {
readonly executable: string;
readonly application: string;
readonly root: string;
};
const observableEventFamilies = [
"app-lifecycle",
"window-lifecycle",
"web-contents-lifecycle",
"navigation",
"shell-attempt",
"process-lifecycle",
"permission",
"popup-attempt",
"download",
"protocol",
"preload",
"preload-configuration",
"renderer-ipc",
"native-addon",
"updater",
"error",
"ipc",
] as const;
const ipcEventKinds = new Set<ElectronHookEvent["kind"]>([
"main-handler-invocation",
"main-event-invocation",
"utility-process-fork",
"utility-process-message",
"ipc-main-to-renderer",
"ipc-utility-to-main",
"ipc-renderer-send",
"ipc-renderer-invoke",
"ipc-renderer-post-message",
]);
const createCoverage = (hookSnapshot: ElectronHookSnapshot) => {
const observedEventFamilies: string[] = [
...new Set(
hookSnapshot.events.map(({ kind, process_type }) => {
if (kind === "preload" && process_type === "main")
return "preload-configuration";
if (kind.startsWith("ipc-renderer") && process_type !== "renderer")
return "ipc";
return ipcEventKinds.has(kind) ? "ipc" : kind;
}),
),
].sort();
const unavailableEventFamilies = new Set(
observableEventFamilies.filter(
(family) => !observedEventFamilies.includes(family),
),
);
const observedRoles = [
...new Set(
hookSnapshot.events.flatMap(({ process_type, kind }) => [
...(process_type === null ? [] : [process_type]),
...(kind === "window-lifecycle" ? ["window"] : []),
...(kind === "web-contents-lifecycle" || kind === "navigation"
? ["web_contents"]
: []),
...(kind === "preload" && process_type === "preload"
? ["preload"]
: []),
...(kind.startsWith("ipc-renderer") && process_type === "renderer"
? ["renderer"]
: []),
]),
),
].sort();
const rendererContextObserved = hookSnapshot.events.some(
({ process_type }) =>
process_type === "renderer" || process_type === "preload",
);
if (!rendererContextObserved) {
unavailableEventFamilies.add("preload");
unavailableEventFamilies.add("renderer-ipc");
}
return {
status: hookSnapshot.hook_error
? "hook_conflict"
: hookSnapshot.truncated
? "capture_truncated"
: "partial_attach",
observed_event_families: observedEventFamilies,
unavailable_event_families: [...unavailableEventFamilies].sort(),
observed_roles: observedRoles,
pre_capture_activity: "unavailable",
} as const;
};
/** Launch an owned Electron application through the official Playwright API. */
export class PlaywrightElectronActiveProvider
implements ElectronActiveObservationPort
{
identity(): ProviderIdentity {
return PLAYWRIGHT_ELECTRON_ACTIVE_PROVIDER_IDENTITY;
}
async capture(
input: ElectronActiveObservationInput,
options: ExecutionOptions = {},
): Promise<Result<ElectronActiveObservationResult, AnalysisError>> {
let application: ElectronApplication | undefined;
let ownership: OwnedProcessGroup | undefined;
let lineage: ProcessLineageObservation | undefined;
let outcome: Result<ElectronActiveObservationResult, AnalysisError>;
const runId = randomUUID();
try {
if (options.signal?.aborted === true)
throw new BrowserObservationError(OPERATION, "cancelled");
const deadline = Date.now() + input.limits.max_duration_ms;
const paths = await canonicalPaths(input);
application = await electron.launch({
executablePath: paths.executable,
cwd: paths.root,
env: safeElectronEnvironment(
input.limits.max_ipc_events,
input.limits.max_runtime_events,
runId,
),
args: ["-r", hookPath, paths.application, ...input.args],
timeout: Math.max(1, deadline - Date.now()),
});
const leaderPid = application.process().pid;
if (
typeof leaderPid !== "number" ||
!Number.isSafeInteger(leaderPid) ||
leaderPid <= 0
)
throw new BrowserObservationError(
OPERATION,
"process_ownership_unavailable",
);
ownership = {
runId,
leaderPid,
processGroupId: leaderPid,
expectedParentPid: process.pid,
expectedCommand: paths.executable,
sweepTokenOwnedProcesses: true,
};
const actions = await runElectronActions(
application,
input,
options,
deadline,
);
const state = await runWithExecutionLimits(
readApplicationState(application, input.limits.max_windows),
options.signal,
deadline,
);
outcome = ok(createResult(paths, input, actions, state));
} catch (cause: unknown) {
outcome = err(providerError(cause));
}
if (application !== undefined) {
if (ownership !== undefined)
lineage = await observeOwnedProcessLineage(ownership);
let closeError: unknown;
try {
await runWithExecutionLimits(
application.close(),
undefined,
Date.now() + 5_000,
);
} catch (cause: unknown) {
closeError = cause;
}
const cleanup =
ownership === undefined
? ({
cleaned: false,
reason: "owned Electron process identity was unavailable",
} satisfies ProcessCleanupResult)
: await cleanupElectronProcesses(ownership, lineage);
if (!cleanup.cleaned)
return err(
providerError(
new BrowserObservationError(OPERATION, "cleanup_failed", {
cause: new Error(cleanup.reason),
}),
),
);
if (closeError !== undefined) return err(providerError(closeError));
}
return outcome;
}
}
const cleanupElectronProcesses = async (
ownership: OwnedProcessGroup,
lineage: ProcessLineageObservation | undefined,
): Promise<ProcessCleanupResult> => {
if (process.platform === "win32") {
const root = await cleanupWindowsProcessTree(ownership.leaderPid);
if (!root.cleaned) return root;
if (lineage?.status !== "verified")
return {
cleaned: false,
reason:
"owned Electron lineage was unavailable; helper cleanup was not proven",
};
for (const descendant of lineage.lineage.descendants) {
const result = await cleanupWindowsProcessTree(descendant.pid);
if (!result.cleaned) return result;
}
return root;
}
if (lineage?.status !== "verified") {
const root = await cleanupOwnedProcessGroup(ownership);
return root.cleaned
? {
cleaned: false,
reason:
"owned Electron lineage was unavailable; helper cleanup was not proven",
}
: root;
}
const groupIds = selectCapturedProcessGroupIds(
ownership.leaderPid,
lineage.lineage.descendants.map(({ pid, processGroupId }) => ({
pid,
process_group_id: processGroupId,
})),
);
let signaled = false;
for (const processGroupId of groupIds) {
const cleanupOwnership: OwnedProcessGroup =
processGroupId === ownership.processGroupId
? { ...ownership, leaderPid: processGroupId, processGroupId }
: {
runId: ownership.runId,
leaderPid: processGroupId,
processGroupId,
};
const result = await cleanupOwnedProcessGroup(cleanupOwnership);
if (!result.cleaned) return result;
signaled ||= result.signaled;
}
const remaining = await verifyNoTokenOwnedProcesses(ownership.runId);
if (!remaining.cleaned) return remaining;
return { cleaned: true, signaled };
};
const createResult = (
paths: ElectronPaths,
input: ElectronActiveObservationInput,
actions: ElectronActiveObservationResult["actions"],
state: {
readonly windows: ReadonlyArray<{
readonly window_id: string;
readonly web_contents_id: string;
readonly url: string;
readonly title: string;
readonly visible: boolean | null;
readonly destroyed: boolean;
}>;
readonly windowsTruncated: boolean;
readonly metrics: ElectronMetrics;
readonly electronVersion: string;
readonly hookSnapshot: ElectronHookSnapshot;
},
): ElectronActiveObservationResult => {
const { hookSnapshot } = state;
const ipcEvents = hookSnapshot.events.filter(({ kind }) =>
ipcEventKinds.has(kind),
);
return electronActiveObservationResultSchema.parse({
schema_version: 1,
application: {
executable_path: paths.executable,
application_path: paths.application,
electron_version: state.electronVersion,
process_ownership: "provider-owned",
cleanup: "terminated-owned-process",
},
actions,
windows: state.windows.slice(0, input.limits.max_windows),
windows_truncated: state.windowsTruncated,
processes: {
items: state.metrics.slice(0, input.limits.max_processes),
truncated: state.metrics.length > input.limits.max_processes,
},
ipc: {
events: ipcEvents.slice(0, input.limits.max_ipc_events),
observed: hookSnapshot.observed_ipc,
retained: Math.min(ipcEvents.length, input.limits.max_ipc_events),
truncated:
hookSnapshot.truncated ||
ipcEvents.length > input.limits.max_ipc_events,
},
timeline: {
events: hookSnapshot.events.slice(0, input.limits.max_runtime_events),
observed: hookSnapshot.observed,
retained: Math.min(
hookSnapshot.events.length,
input.limits.max_runtime_events,
),
truncated:
hookSnapshot.truncated ||
hookSnapshot.events.length > input.limits.max_runtime_events,
},
coverage: createCoverage(hookSnapshot),
limitations: [
"IPC payloads are represented only by bounded value shapes; values are never retained, channels are capped at 1,024 characters, and argument-shape arrays at 32 entries.",
"IPC direction and sender/receiver identifiers are observed only where Electron exposes them at the hooked boundary.",
"The runtime timeline records bounded lifecycle, navigation, shell, permission, popup, download, protocol, preload, native-addon, process, and IPC events; activity before hook installation is unavailable.",
"The preload and renderer process contexts are not instrumented by the main-process -r hook; preload configuration and contextBridge API-shape events are main-boundary observations, not proof of renderer-side execution.",
"Process metrics are an Electron API snapshot and do not prove hostile-local-user isolation.",
"External shell opens, external navigation, permission grants, downloads, popup windows, updater relaunches, and OS integration are blocked and recorded by the active hook; other application filesystem and network behavior is not sandboxed by this provider.",
...(state.windowsTruncated
? ["Windows beyond max_windows were not retained."]
: []),
...(hookSnapshot.hook_error
? ["The active IPC hook could not be installed."]
: []),
],
});
};
const canonicalPaths = async (
input: ElectronActiveObservationInput,
): Promise<ElectronPaths> => {
const [executable, application, root] = await Promise.all([
realpath(input.executable_path),
realpath(input.application_path),
realpath(input.application_root),
]);
if (!isPathContained(root, application))
throw new BrowserObservationError(OPERATION, "target_not_allowed");
return { executable, application, root };
};
const safeElectronEnvironment = (
maxIpcEvents: number,
maxRuntimeEvents: number,
runId: string,
): Record<string, string> =>
Object.fromEntries([
...[
"HOME",
"LANG",
"PATH",
"TMP",
"TMPDIR",
"USER",
"DISPLAY",
"WAYLAND_DISPLAY",
]
.map((name) => [name, process.env[name]] as const)
.filter(
(entry): entry is readonly [string, string] => entry[1] !== undefined,
),
["REA_ELECTRON_ACTIVE_MAX_IPC_EVENTS", String(maxIpcEvents)] as const,
[
"REA_ELECTRON_ACTIVE_MAX_RUNTIME_EVENTS",
String(maxRuntimeEvents),
] as const,
["REA_PROCESS_RUN_ID", runId] as const,
]);
const providerError = (cause: unknown): AnalysisError =>
cause instanceof AnalysisError
? cause
: new ProviderAdapterError(
PLAYWRIGHT_ELECTRON_ACTIVE_PROVIDER_IDENTITY.id,
OPERATION,
{
cause,
},
);
+4 -4
View File
@@ -138,9 +138,9 @@ export const openPlaywrightScenarioBrowser = async (
);
await configureAttachedEnvironment(target.context, target.page, scenario);
return { ...target, browser, profilePath: undefined };
} catch (error) {
} catch (cause: unknown) {
await browser.close();
throw error;
throw cause;
}
}
@@ -174,9 +174,9 @@ export const openPlaywrightScenarioBrowser = async (
}
const page = context.pages()[0] ?? (await context.newPage());
return { context, page, browser, profilePath };
} catch (error) {
} catch (cause: unknown) {
await rm(profilePath, { recursive: true, force: true, maxRetries: 3 });
throw error;
throw cause;
}
};
+4 -4
View File
@@ -297,11 +297,11 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
remaining(),
options.signal,
);
} catch (error) {
} catch (cause: unknown) {
void opening
.then((lateOpened) => closePlaywrightScenarioBrowser(lateOpened))
.catch(() => undefined);
throw error;
throw cause;
}
try {
await withPlaywrightExecutionBoundary(
@@ -336,9 +336,9 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
options.signal,
);
return session;
} catch (error) {
} catch (cause: unknown) {
await closePlaywrightScenarioBrowser(opened);
throw error;
throw cause;
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
const SECRET_ASSIGNMENT =
/\b(authorization|api[_-]?key|access[_-]?token|refresh[_-]?token|password|passwd|secret|cookie|session)\b\s*[:=]\s*(?:Bearer\s+[A-Za-z0-9._~+/=-]+|"[^"]*"|'[^']*'|[^\s,;]+)/giu;
/\b(authorization|api[_-]?key|access[_-]?token|refresh[_-]?token|token|password|passwd|secret|cookie|session)\b\s*[:=]\s*(?:Bearer\s+[A-Za-z0-9._~+/=-]+|"[^"]*"|'[^']*'|[^\s,;]+)/giu;
const BEARER = /\bBearer\s+[A-Za-z0-9._~+/=-]+/giu;
const JWT = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/gu;
@@ -30,6 +30,7 @@ export const describeInspectorTargetLimitations = (): string[] => [
"REA attaches to an already-running exact target and never launches, resumes, evaluates, pauses, or mutates it.",
"Only Runtime.enable and Debugger.enable are sent; source text, object values, EventEmitter activity, and Electron IPC are not inspected.",
"Target IDs and locations are authorized, but the Inspector protocol does not authenticate an operating-system process ID or Electron role.",
"Some Electron main targets report only file://; for those targets, the first approved file root is recorded as a scope fallback and script locations are authorized independently.",
"No runtime graph depth is traversed because passive Inspector events do not establish require/import caller edges.",
];
+5 -3
View File
@@ -27,7 +27,7 @@ import {
} from "./CdpCaptureValues.js";
import { CdpConnection, type CdpEvent } from "./CdpConnection.js";
import {
authorizeRuntimeLocation,
authorizeRuntimeTargetLocation,
canonicalRuntimeRoots,
} from "./JavaScriptRuntimeScope.js";
import {
@@ -95,8 +95,9 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort {
const allowed: AuthorizedV8InspectorTarget[] = [];
const excluded = createInspectorExclusionCounts();
for (const target of discovery.targets) {
const decision = await authorizeRuntimeLocation(
const decision = await authorizeRuntimeTargetLocation(
target.url,
target.type,
roots,
input.allowed_origins,
);
@@ -202,8 +203,9 @@ const authorizedTarget = async (
"observe_javascript_runtime",
"target_not_found",
);
const decision = await authorizeRuntimeLocation(
const decision = await authorizeRuntimeTargetLocation(
target.url,
target.type,
roots,
input.allowed_origins,
);
+12 -6
View File
@@ -16,12 +16,18 @@ const toolCatalog = TOOL_CONTRACTS.map((contract) => ({
description: contract.description,
effects: { ...contract.effects },
annotations: contract.annotations,
input_schema: z.toJSONSchema(contract.inputSchema, {
unrepresentable: "any",
}),
output_schema: z.toJSONSchema(contract.outputSchema, {
unrepresentable: "any",
}),
input_schema: {
type: "object",
...z.toJSONSchema(contract.inputSchema, {
unrepresentable: "any",
}),
},
output_schema: {
type: "object",
...z.toJSONSchema(contract.outputSchema, {
unrepresentable: "any",
}),
},
})).sort((left, right) => left.name.localeCompare(right.name));
const promptCatalog = PROMPT_CONTRACTS.map((contract) => ({
+1 -1
View File
@@ -489,7 +489,7 @@ const registerPlanManagedRuntimeCorrelation = (
if (!authority.ok) return projectAnalysisError(authority.error);
const result = await planManagedRuntimeCorrelationEvidence(
{
policy: config.value.managedRuntimePolicy,
policy: () => config.value.managedRuntimePolicy,
authority: authority.value,
},
input.value,
+2 -2
View File
@@ -127,7 +127,7 @@ export const registerApplicationCommands = (
if (!authority.ok) return projectAnalysisError(authority.error);
const result = await runControlledReplay(
{
policy: config.value.javascriptReplayPolicy,
policy: () => config.value.javascriptReplayPolicy,
host: new SystemJavaScriptReplayHost(),
runner: new LinuxJavaScriptReplayRunner(),
authority: authority.value,
@@ -318,7 +318,7 @@ const replayDependencies = (
config: AppConfig,
authority: PermissionAuthority,
) => ({
policy: config.javascriptReplayPolicy,
policy: () => config.javascriptReplayPolicy,
host: new SystemJavaScriptReplayHost(),
runner: new LinuxJavaScriptReplayRunner(),
authority,
+21 -5
View File
@@ -10,7 +10,11 @@ import { loadConfiguredPermissionAuthority } from "./application/PermissionConfi
import { CdpBrowserProvider } from "./browser/CdpBrowserProvider.js";
import { logCliCommand } from "./cliLogging.js";
import { parseConfig } from "./config.js";
import { AnalysisInputError, projectAnalysisError } from "./domain/errors.js";
import {
AnalysisCapabilityUnavailableError,
AnalysisInputError,
projectAnalysisError,
} from "./domain/errors.js";
import { browserCaptureComparisonInputSchema } from "./domain/browserCaptureComparison.js";
import { discoverWebMcpToolsInputSchema } from "./domain/webMcpDiscovery.js";
import {
@@ -92,7 +96,7 @@ const registerWebMcp = (
}),
run: ({ args, options }) =>
logCliCommand(logger, "discover-webmcp-tools", async () => {
const context = await browserContext();
const context = await browserContext("discover_webmcp_tools");
if (!context.ok) return context.error;
const parsed = discoverWebMcpToolsInputSchema.safeParse({
cdp_endpoint: args.endpoint,
@@ -197,7 +201,7 @@ const registerScreenshot = (
}),
run: ({ args, options }) =>
logCliCommand(logger, "capture-web-screenshot", async () => {
const context = await browserContext();
const context = await browserContext("capture_web_screenshot");
if (!context.ok) return context.error;
const parsed = captureWebScreenshotInputSchema.safeParse({
cdp_endpoint: args.endpoint,
@@ -263,9 +267,21 @@ const registerScreenshotDiff = (
});
};
const browserContext = async () => {
const browserContext = async (operation: string) => {
const config = parseConfig(process.env);
if (!config.ok) return { ok: false as const, error: cliError(config.error) };
const policy = config.value.browserObservationPolicy;
if (policy.status === "disabled")
return {
ok: false as const,
error: cliError(
new AnalysisCapabilityUnavailableError(
"rea-cdp-browser",
operation,
"browser observation is disabled; configure exact endpoints and origins before enabling it",
),
),
};
const authority = await loadConfiguredPermissionAuthority(config.value);
if (!authority.ok)
return { ok: false as const, error: cliError(authority.error) };
@@ -273,7 +289,7 @@ const browserContext = async () => {
ok: true as const,
authority: authority.value,
provider: new CdpBrowserProvider(),
allowedBrowserOrigins: config.value.browserAllowedOrigins,
allowedBrowserOrigins: policy.allowedOrigins,
};
};
+24 -7
View File
@@ -16,7 +16,11 @@ import {
} from "./domain/browserObservation.js";
import { analyzeWebBundleInputSchema } from "./domain/webBundleAnalysis.js";
import { observeWebSessionInputSchema } from "./domain/browserSession.js";
import { AnalysisInputError, projectAnalysisError } from "./domain/errors.js";
import {
AnalysisCapabilityUnavailableError,
AnalysisInputError,
projectAnalysisError,
} from "./domain/errors.js";
import type { JsonValue } from "./domain/jsonValue.js";
import type { Logger } from "./logger.js";
import { CLI_COMMANDS } from "./cliCommandNames.js";
@@ -81,7 +85,7 @@ const registerTargetList = (
}),
run: ({ args, options }) =>
logCliCommand(logger, "list-browser-targets", async () => {
const context = await browserContext();
const context = await browserContext("list_browser_targets");
if (!context.ok) return context.error;
const parsed = listBrowserTargetsInputSchema.safeParse({
cdp_endpoint: args.endpoint,
@@ -116,7 +120,7 @@ const registerPageInspection = (
options: browserPageInspectionOptions,
run: ({ args, options }) =>
logCliCommand(logger, "inspect-web-page", async () => {
const context = await browserContext();
const context = await browserContext("inspect_web_page");
if (!context.ok) return context.error;
const parsed = inspectWebPageInputSchema.safeParse({
cdp_endpoint: args.endpoint,
@@ -133,6 +137,7 @@ const registerPageInspection = (
include_websocket_shapes: options.includeWebsocketShapes,
websocket_shape_approved: options.websocketShapeApproved,
include_script_sources: options.includeScriptSources,
source_capture_approved: options.sourceCaptureApproved,
include_storage_keys: options.includeStorageKeys,
include_storage_fingerprints: options.includeStorageFingerprints,
limits: {
@@ -227,7 +232,7 @@ const registerBundleAnalysis = (
}),
run: ({ args, options }) =>
logCliCommand(logger, "analyze-web-bundle", async () => {
const context = await browserContext();
const context = await browserContext("analyze_web_bundle");
if (!context.ok) return context.error;
const parsed = analyzeWebBundleInputSchema.safeParse({
cdp_endpoint: args.endpoint,
@@ -283,7 +288,7 @@ const registerObservationSession = (
}),
run: ({ args, options }) =>
logCliCommand(logger, "observe-web-session", async () => {
const context = await browserContext();
const context = await browserContext("observe_web_session");
if (!context.ok) return context.error;
const parsed = observeWebSessionInputSchema.safeParse({
cdp_endpoint: args.endpoint,
@@ -306,9 +311,21 @@ const registerObservationSession = (
});
};
const browserContext = async () => {
const browserContext = async (operation: string) => {
const config = parseConfig(process.env);
if (!config.ok) return { ok: false as const, error: cliError(config.error) };
const policy = config.value.browserObservationPolicy;
if (policy.status === "disabled")
return {
ok: false as const,
error: cliError(
new AnalysisCapabilityUnavailableError(
"rea-cdp-browser",
operation,
"browser observation is disabled; configure exact endpoints and origins before enabling it",
),
),
};
const authority = await loadConfiguredPermissionAuthority(config.value);
if (!authority.ok)
return { ok: false as const, error: cliError(authority.error) };
@@ -316,7 +333,7 @@ const browserContext = async () => {
ok: true as const,
authority: authority.value,
provider: new CdpBrowserProvider(),
allowedBrowserOrigins: config.value.browserAllowedOrigins,
allowedBrowserOrigins: policy.allowedOrigins,
};
};
Loaded 100 of 384 files, more files were not shown because too many files have changed in this diff. Show more