diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77b3825c6..a82e459b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -188,6 +188,51 @@ jobs: - run: npm ci - run: npm run verify:package + reconstruction-readiness: + name: Reconstruction readiness + needs: changes + if: github.event_name == 'push' || needs.changes.outputs.code == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + - run: npm ci + - name: Run the public CLI/MCP reconstruction journey + env: + REA_READINESS_REPORT_PATH: readiness-report.json + run: | + set -o pipefail + npm run verify:readiness | tee readiness-summary.json + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: reconstruction-readiness + path: | + readiness-summary.json + readiness-report.json + if-no-files-found: error + retention-days: 30 + + javascript-runtime-observation: + name: JavaScript runtime observation + needs: changes + if: github.event_name == 'push' || needs.changes.outputs.code == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + - run: npm ci + - name: Verify real Node Inspector discovery and observation + run: npm run verify:inspector + windows-curated: name: Windows Ghidra P0 and package lane needs: changes diff --git a/AGENTS.md b/AGENTS.md index 262217f08..7bd6207be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,7 @@ See [docs/architecture.mermaid](docs/architecture.mermaid) for a visual architec - `src/contracts/`: caller-visible tool schemas and catalog metadata; `toolContracts.ts` owns the canonical inventory and `enhancedInputs.ts` owns enhanced input parsing. - `src/process/`: provider-neutral process ownership and lifecycle primitives. It owns private runtime roots, session-assigned run identity and token-verified lineage, absolute startup deadlines, correlated request waits, bounded output capture, and TERM-to-KILL cleanup without defining any provider wire protocol. - `src/replay/`: Linux x64 controlled-JavaScript-replay adapter. It owns exact runtime closure inspection, Bubblewrap/seccomp/cgroup admission, the disposable worker, strict parent/worker protocol validation, and complete cleanup observation. -- `src/browser/`: loopback CDP discovery, bounded WebSocket transport, exact-origin target authorization, and passive browser observation normalization. +- `src/browser/`: loopback CDP/Inspector discovery, bounded WebSocket transport, exact-origin or canonical-root target authorization, passive browser/Electron and attach-only Node/Electron V8 observation, plus controlled Playwright scenarios with explicit launch/attach ownership and cleanup. - `src/hopper/`: Hopper launch and Unix-socket protocol mechanics. `BridgeLauncher.ts` spawns the Hopper app with the in-process bridge, `HopperClient.ts` correlates request/response over the socket with timeouts and cancellation, `protocol.ts` frames bridge messages. - `bridge/hopper_bridge.py`: runs inside Hopper and adapts declared operations to Hopper's public Python API. Hopper's bundled MCP server is not used. - `src/ghidra/`: exact Ghidra 12.1.2/JDK 21 inspection, analysis-profile commitment, digest-bound target snapshots, isolated `analyzeHeadless` launch, authenticated Unix-socket or Windows loopback transport, bounded serial request queue, and strict inventory/function boundaries. @@ -74,7 +74,8 @@ See [docs/architecture.mermaid](docs/architecture.mermaid) for a visual architec - `npm run verify:hopper`: build and run the real-Hopper verifier with two distinct binaries. - `npm run verify:ghidra`: build and run the real-Ghidra verifier against `GHIDRA_INSTALL_DIR` and optional `GHIDRA_TARGET_PATH`. - `npm run verify:ghidra:windows`: build and run the real Windows Ghidra verifier against `GHIDRA_INSTALL_DIR` and the source-owned x64 PE fixture. -- `npm run verify:browser`: build and run the real Chrome verifier against `REA_BROWSER_EXECUTABLE` or a platform-default Chrome-family executable. +- `npm run verify:browser`: build and run the real Chrome verifier against `REA_BROWSER_EXECUTABLE` or a platform-default Chrome-family executable, including browser-scenario CLI parity, attach disconnect-only ownership, launched-process termination, and temporary-profile cleanup. +- `npm run verify:inspector`: build and run the real attach-only Node Inspector verifier through the public CLI. - `npm run verify:managed`: build and run the source-owned managed PE/CLI conformance verifier for artifact triage, member inspection, managed/native boundary declarations, token drift comparison, malformed metadata, non-managed degradation, managed application-graph projection, manifest-verifier self-test, and optional BYO ILSpy oracle checks; set `REA_MANAGED_APP_MANIFEST_PATH` to verify an operator-local managed app manifest, including optional graph/trace assertions, and set `REA_ILSPY_CMD_PATH` to an absolute `ilspycmd` path to run the real ILSpy reconstruction oracle. - `npm run evidence:generate`: regenerate the managed conformance manifest and Evidence v2 completion ledger from live verifier output. - `npm run evidence:check`: rerun managed conformance and fail on generated manifest, completion ledger, or bundled skill drift. @@ -102,9 +103,19 @@ Pre-commit hooks via Husky format then lint staged source files. Pre-push runs ` - `REA_BROWSER_OBSERVE_ENABLED` (optional, default `false`): add browser observation authority to the administrator ceiling. - `REA_BROWSER_CDP_ENDPOINTS_JSON` (optional): approved literal loopback CDP HTTP endpoints. - `REA_BROWSER_ALLOWED_ORIGINS_JSON` (optional): exact HTTP(S) page origins approved for passive observation. +- `REA_BROWSER_SCENARIO_ENABLED` (optional, default `false`): add controlled browser automation to the administrator ceiling. +- `REA_BROWSER_SCENARIO_AUTO_GRANT` (optional, default `false`): issue the exact configured browser-automation ceiling as an administrator grant for trusted unattended use. +- `REA_BROWSER_SCENARIO_EXECUTABLE_ROOTS_JSON` (optional): canonical executable roots approved for provider-owned browser launch. +- `REA_BROWSER_SCENARIO_CDP_ENDPOINTS_JSON` (optional): literal-loopback CDP endpoints approved for external-browser attachment. +- `REA_BROWSER_SCENARIO_ALLOWED_ORIGINS_JSON` (optional): exact HTTP(S) page origins approved for browser scenarios. +- `REA_BROWSER_SCENARIO_ALLOWED_ENV_JSON` (optional): environment-variable names that scenario secret references may resolve. - `REA_ELECTRON_OBSERVE_ENABLED` (optional, default `false`): add passive Electron file-page observation authority to the administrator ceiling. - `REA_ELECTRON_CDP_ENDPOINTS_JSON` (optional): approved literal loopback Electron CDP HTTP endpoints. - `REA_ELECTRON_FILE_ROOTS_JSON` (optional): canonical filesystem roots approved for passive Electron page observation. +- `REA_V8_INSPECTOR_OBSERVE_ENABLED` (optional, default `false`): add passive attach-only Node/Electron Inspector observation authority to the administrator ceiling. +- `REA_V8_INSPECTOR_ENDPOINTS_JSON` (optional): approved literal-loopback Node/Electron Inspector HTTP endpoints. +- `REA_V8_INSPECTOR_FILE_ROOTS_JSON` (optional): canonical filesystem roots approved for Inspector targets and script locations. +- `REA_V8_INSPECTOR_ALLOWED_ORIGINS_JSON` (optional): exact HTTP(S) origins approved for Electron renderer Inspector targets and scripts. - `REA_INVESTIGATION_INPUT_ROOTS_JSON` (optional): canonical filesystem roots approved for static JavaScript/Electron application analysis and other investigation inputs. - `REA_JAVASCRIPT_REPLAY_ENABLED` (optional, default `false`): add controlled extracted-module execution authority to the administrator ceiling. - `REA_JAVASCRIPT_REPLAY_ROOTS_JSON` (optional): exact canonical source roots approved for controlled replay. diff --git a/README.md b/README.md index 13d10fdb4..9fbc95697 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ See a feature in an app that you want in your own product? Give the app to your agent—even without its source code. With REA, the agent can investigate the feature, explain how it works, show its evidence, and build a version adapted to your stack and requirements. -REA gives agents one consistent way to investigate software. Today that includes deep native analysis and function dossiers through Hopper or bring-your-own Ghidra on Linux, plus an experimental Windows x64 Ghidra P0 for approved native PE applications; execution-free managed PE/CLI triage; reproducible Evidence v2 records; controlled process capture; passive website and Electron observation; bounded JavaScript/source-map reconstruction; and a versioned domain graph for connecting JavaScript application layers without confusing static inference with runtime observation. The longer-term toolkit extends the same agent workflow to APIs, protocols, mobile artifacts, firmware, richer runtime behavior, and differences between versions. +REA gives agents one consistent way to investigate software. Today that includes deep native analysis and function dossiers through Hopper or bring-your-own Ghidra on Linux, plus an experimental Windows x64 Ghidra P0 for approved native PE applications; execution-free managed PE/CLI triage; reproducible Evidence v2 records; controlled process capture; passive website, Electron page, and Node/Electron V8 Inspector observation; bounded JavaScript/source-map reconstruction; and a versioned domain graph for connecting JavaScript application layers without confusing static inference with runtime observation. The longer-term toolkit extends the same agent workflow to APIs, protocols, mobile artifacts, firmware, richer runtime behavior, and differences between versions. Reverse engineering normally makes the operator choose a tool, learn its API, move evidence between programs, and decide what to inspect next. REA gives that work to the agent through commands, skills, structured results, and repeatable investigation workflows. @@ -371,17 +371,18 @@ REA handles the app analysis in steps 1–5. The agent performs step 6 with its ## Tool catalog for investigation -| Tool family | Count | Examples | -| ------------------------- | ----: | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Native inspection | 34 | procedures, pseudocode, assembly, strings, names, segments, callers, callees, xrefs, annotations | -| Investigation workflows | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `trace_feature`, call graphs, Swift and Objective-C discovery | -| Native macOS utilities | 5 | Mach-O metadata, code signatures, plists, architectures, Swift demangling; Hopper-free and provenance-bearing | -| Artifact graph | 2 | deterministic directory, ZIP/APK/IPA/MSIX/AppX, and ASAR inventory; explicitly selected extraction into an absent owned tree | -| Managed PE/CLI | 8 | PE/CLI identity, metadata members, CIL hashes, P/Invoke/native-boundary declarations and verification, application-graph projection, decompiler reconstruction import, token remapping, runtime-correlation plans, and version comparison | -| Browser observation | 8 | exact-origin CDP capture, bundle and source-map analysis, WebMCP discovery, session timelines, capture diff, and visual evidence | -| Electron analysis | 4 | passive root-confined observation, bounded static application mapping, and evidence-backed static/runtime reconciliation | -| Application workflows | 9 | bounded cross-layer traces, unique-only version matching, static export return-shape comparison, approved Linux-isolated extracted-module replay, managed-runtime characterization, and reconstruction coverage closure | -| Workspace and observation | 19 | target lifecycle, Evidence v2 bundles, direct finite replay-machine evaluation, process/artifact/function comparison, evidence-linked residual-unknown lifecycle | +| Tool family | Count | Examples | +| ------------------------- | ----: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Native inspection | 36 | procedures, pseudocode, assembly, strings, names, segments, callers, callees, xrefs, annotations, bounded byte reads, file-offset translation | +| Investigation workflows | 12 | `binary_overview`, `analyze_function`, `batch_decompile`, `trace_feature`, exact string-to-code lookup, bounded call paths, call graphs, Swift and Objective-C discovery | +| Native macOS utilities | 5 | Mach-O metadata, code signatures, plists, architectures, Swift demangling; Hopper-free and provenance-bearing | +| Artifact graph | 3 | bounded provider-neutral inspection, deterministic directory/ZIP/APK/IPA/MSIX/AppX/ASAR inventory, and explicitly selected extraction into an absent owned tree | +| Managed PE/CLI | 8 | PE/CLI identity, metadata members, CIL hashes, P/Invoke/native-boundary declarations and verification, application-graph projection, decompiler reconstruction import, token remapping, runtime-correlation plans, and version comparison | +| Browser observation | 9 | exact-origin passive CDP capture, bundle and source-map analysis, WebMCP discovery, session timelines, capture diff, visual evidence, and bounded Playwright scenarios | +| Electron analysis | 4 | passive root-confined observation, bounded static application mapping, and evidence-backed static/runtime reconciliation | +| JavaScript runtime | 2 | approved attach-only Node/Electron Inspector target discovery plus bounded script and execution-context observation without evaluation or instrumentation | +| Application workflows | 12 | bounded cross-layer traces, unique-only version matching, historical-source to bundle mapping, static export return-shape comparison, approved Linux-isolated extracted-module replay, managed-runtime characterization, reconstruction coverage closure, deterministic obligation ledgers, and end-to-end readiness conformance | +| Workspace and observation | 19 | target lifecycle, Evidence v2 bundles, direct finite replay-machine evaluation, process/artifact/function comparison, evidence-linked residual-unknown lifecycle | The public interface describes what the agent is trying to learn. Providers decide how to answer. macOS utilities handle common semantic inspection without launching Hopper; Hopper handles deeper native analysis; the process harness implements controlled behavioral capture. @@ -393,10 +394,11 @@ REA is already useful for native application, browser, and Electron investigatio - Discover deep-analysis candidates without starting them, choose deterministically, and retain one immutable provider/profile binding until an explicit switch or close; provider failures never trigger transparent fallback. - Attach to a user-owned Chrome-family browser over a configured loopback CDP endpoint; capture exact-origin web structure, safe metadata, approved value-free payload shapes, bundle/source-map evidence, WebMCP declarations, user-action timelines, capture diffs, and explicitly approved screenshots without navigation or JavaScript evaluation. - Inspect Electron `file://` renderer pages through a separate canonical-root permission boundary without invoking Electron APIs; script contents remain separately approved and byte bounded. +- Attach to one exact approved Node or Electron V8 Inspector target and retain bounded `scriptParsed` plus execution-context lifecycle metadata without evaluation, breakpoints, resume, source reads, or instrumentation. require/import edges, EventEmitter activity, Electron IPC, PID identity, and role identity remain unknown. See [passive Node and Electron runtime observation](docs/javascript-runtime-observation.md). - Validate and canonically serialize a provider-neutral [JavaScript Application Graph v1](docs/javascript-application-graph.md) spanning packages, ASAR entries, Electron roles, JavaScript/source-map entities, browser/runtime instances, IPC, endpoints, storage, and native add-ons. This shipped domain contract performs no extraction or I/O by itself. - Reconstruct bounded static package, entrypoint, Webpack/Rspack module, import, worker, endpoint, storage, source-map, BrowserWindow, preload, contextBridge, IPC, utility-process, and native-add-on structure from an approved local directory or ASAR through `analyze_javascript_application` or `rea analyze-javascript-application`. The AST-only [application service](docs/javascript-artifact-reconstruction.md) never executes bootstrap code, pairs only unique exact literal IPC channels, and reports dynamic or ambiguous channels as unresolved. - Reconcile that static graph with existing passive web or Electron Evidence through `reconcile_javascript_runtime` or `rea reconcile-javascript-runtime`. Exact captured bytes outrank caller-declared file/URL mappings; target, frame, script, worker, cache, and asset ambiguity stays explicit, source-map authority stays separate, and a module resident in an observed bundle is never reported as executed. See [JavaScript static/runtime reconciliation](docs/javascript-runtime-reconciliation.md). -- Trace a literal route, string, API, IPC channel, module, or native export through authenticated application Evidence with explicit traversal bounds, then hand exact native artifact digests and requested exports to retained Ghidra or Hopper Evidence without automatic provider switching. Compare application versions using unique-only digest, source-map, structural, and semantic tiers; compare one exact JavaScript export's static return shapes through unique literal discriminants and bounded JSON Pointer changes. Duplicate, dynamic, incomplete, and truncated facts stay unknown. See [cross-layer JavaScript application workflows](docs/javascript-application-workflows.md). +- Trace a literal route, string, API, IPC channel, module, or native export through authenticated application Evidence with explicit traversal bounds, then hand exact native artifact digests and requested exports to retained Ghidra or Hopper Evidence without automatic provider switching. Compare application versions using unique-only digest, source-map, structural, and semantic tiers; map a committed historical source inventory to bundle nodes with explicit digest and path scores; compare one exact JavaScript export's static return shapes through unique literal discriminants and bounded JSON Pointer changes. Duplicate, dynamic, incomplete, ambiguous, and truncated facts stay unknown. See [cross-layer JavaScript application workflows](docs/javascript-application-workflows.md). - Classify PE/CLI managed artifacts with `inspect_managed_artifact` / `rea inspect-managed-artifact`, inspect bounded metadata members, signatures, raw CIL hashes, limited decoded-instruction-tuple v1 hashes, separately reported exception regions, call edges, and field-access anchors with `inspect_managed_members` / `rea inspect-managed-members`, inventory declared ModuleRef/ImplMap/PInvoke and non-IL method boundary indicators with `inspect_managed_native_boundaries` / `rea inspect-managed-native-boundaries`, then compare two authenticated member observations with `compare_managed_members` / `rea compare-managed-members`. `verify_managed_native_boundaries` / `rea verify-managed-native-boundaries` checks managed P/Invoke declarations against authenticated native export or function Evidence while keeping verified, inferred, contradicted, and unresolved states distinct. The comparison treats build-local tokens as build-local and uses unique decoded-CIL/signature and structural method-shape tiers, never names alone; the v1 tuple hash does not itself resolve tokens or fully commit control flow. `project_managed_application_graph` / `rea project-managed-application-graph` projects authenticated managed artifact/member/native-boundary Evidence into the existing application graph for cross-layer feature tracing. `import_managed_reconstruction` / `rea import-managed-reconstruction` admits user-supplied decompiler C#/IL/pseudocode as analyst inference only after exact artifact SHA-256, MVID, signature, and the shipped v1 decoded-IL commitment match. Separately, `plan_managed_runtime_correlation` / `rea plan-managed-runtime-correlation` can admit a default-disabled, permission-gated runtime-correlation plan locked to the same build evidence. These paths never load the assembly, resolve CLR dependencies, execute target code, run a decompiler, or translate managed tokens into native addresses; full normalized-CIL v2 semantics, native-body bridge mapping, and an actual runtime executor remain future managed-code contracts. - Configure `REA_ILSPY_CMD_PATH=/absolute/path/to/ilspycmd` only when you want doctor and `verify:managed` to inspect a bring-your-own ILSpy command as a @@ -438,6 +440,55 @@ rea inspect-web-page http://127.0.0.1:9222 TARGET_ID --approved --json All eight browser tools expose the same Evidence v2 contracts over CLI and MCP. Inspection is passive: REA does not evaluate page JavaScript, navigate, click, close the page, or close the browser. Query values, credentials, cookies, authorization headers, storage values, and raw JSON or WebSocket values are never retained. Separately approved captures can retain bounded redacted console primitives, value-free JSON/WebSocket shapes, script sources, accessibility text, or screenshot pixels. Existing activity before attach is explicitly unavailable. See [Website observation with CDP](docs/browser-observation.md) for browser startup, schemas, limits, and the threat model. +### Controlled browser scenarios + +`capture_browser_scenario` is a separate, explicitly mutating browser boundary. +It runs only the fixed, versioned scenario vocabulary through Playwright and +returns step-indexed Evidence for screenshots, DOM, accessibility, URL/history, +storage, console/errors, network, WebSockets, frames, workers, popups, and +cancelled downloads. Missing or truncated sections can never support equality +claims. + +```bash +export REA_BROWSER_SCENARIO_ENABLED=true +export REA_BROWSER_SCENARIO_EXECUTABLE_ROOTS_JSON='["/usr/bin"]' +export REA_BROWSER_SCENARIO_CDP_ENDPOINTS_JSON='["http://127.0.0.1:9222"]' +export REA_BROWSER_SCENARIO_ALLOWED_ORIGINS_JSON='["http://127.0.0.1:3000"]' +export REA_BROWSER_SCENARIO_ALLOWED_ENV_JSON='["REA_TEST_PASSWORD"]' + +rea capture-browser-scenario ./scenario.json --json +``` + +Launch mode owns a temporary browser profile and removes it after terminating +the launched browser. Connect mode accepts one exact loopback CDP target and +disconnects without closing the external browser. Automation has no default +grant: use the shared project/session policy, or set +`REA_BROWSER_SCENARIO_AUTO_GRANT=true` only for a trusted unattended +environment. Scenario JSON contains secret references and environment-variable +names, never secret values. See the +[browser scenario contract](docs/browser-scenario-contract.md). + +### Node and Electron V8 Inspector observation + +Attach-only JavaScript runtime observation is separately disabled by default: + +```bash +export REA_V8_INSPECTOR_OBSERVE_ENABLED=true +export REA_V8_INSPECTOR_ENDPOINTS_JSON='["http://127.0.0.1:9229"]' +export REA_V8_INSPECTOR_FILE_ROOTS_JSON='["/absolute/path/to/app"]' +export REA_V8_INSPECTOR_ALLOWED_ORIGINS_JSON='[]' + +rea list-javascript-runtime-targets http://127.0.0.1:9229 --approved --json +rea observe-javascript-runtime http://127.0.0.1:9229 TARGET_ID \ + --runtime-kind node --approved --json +``` + +REA sends only `Runtime.enable` and `Debugger.enable`. It retains bounded, +scope-authorized script locations and execution-context lifecycle events; +require/import edges, EventEmitter activity, Electron IPC, PID identity, and +Electron role identity stay explicit unknowns. See +[passive Node and Electron runtime observation](docs/javascript-runtime-observation.md). + Exact package, tool-family, provider, setup-client, public schema-version, and CLI facts are generated from source in [`docs/product-catalog.json`](docs/product-catalog.json). PR CI verifies this catalog, narrative documentation, generated schemas, and a clean TypeDoc render. ## Roadmap diff --git a/README_ar.md b/README_ar.md index 007a83ba2..154386e3b 100644 --- a/README_ar.md +++ b/README_ar.md @@ -159,17 +159,18 @@ rea uninstall --purge-data ## منصة أدوات التحقيق -| عائلة الأدوات | العدد | أمثلة | -| --------------------- | ----: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| فحص الملفات التنفيذية | 34 | الدوال، والشيفرة شبه المصدرية، والتعليمات، والسلاسل، والأسماء، والمراجع، والتعليقات | -| التحليل المركب | 10 | النظرة العامة، وفك الترجمة الدفعي، ومخططات الاستدعاء، والمراجع، واكتشاف الأنواع | -| أدوات macOS الأصلية | 5 | بيانات Mach-O والتوقيعات وملفات plist والمعماريات وفك رموز Swift دون تشغيل Hopper | -| رسم القطع الأثرية | 2 | جرد حتمي للدلائل وZIP/APK/IPA وASAR واستخراج معاملاتي محدد صراحة | -| Managed PE/CLI | 8 | هوية PE/CLI، أعضاء البيانات الوصفية، بصمات CIL، تصريحات P/Invoke/الحدود الأصلية والتحقق منها، إسقاط رسم التطبيق، استيراد إعادة البناء من فك الترجمة، إعادة ربط الرموز، خطط ترابط وقت التشغيل ومقارنة الإصدارات | -| مراقبة المتصفح | 8 | التقاط CDP محدود الأصل وتحليل الحزم وخرائط المصدر واكتشاف WebMCP والفروق والأدلة المرئية | -| تحليل Electron | 4 | مراقبة سلبية ضمن جذور canonical، ورسم ثابت ومحدود للتطبيق، ومطابقة ثابتة/وقت تشغيل قائمة على Evidence | -| سير عمل التطبيقات | 9 | تتبع محدود ومقارنة إصدارات فريدة ومقارنة ثابتة لأشكال قيم إرجاع الصادرات وإعادة تشغيل وحدات معزولة ومعتمدة على Linux وتوصيف وقت التشغيل المُدار وإغلاق تغطية إعادة البناء | -| جلسة الملف التنفيذي | 19 | فتح الأهداف وحزم الأدلة وتشغيل آلة إعادة محدودة ومقارنة العمليات والقطع والدوال وسجل المجهولات المتبقية | +| عائلة الأدوات | العدد | أمثلة | +| --------------------- | ----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| فحص الملفات التنفيذية | 36 | الدوال، والشيفرة شبه المصدرية، والتعليمات، والسلاسل، والأسماء، والمراجع، والتعليقات | +| التحليل المركب | 12 | النظرة العامة، وفك الترجمة الدفعي، ومخططات الاستدعاء، والمراجع، واكتشاف الأنواع | +| أدوات macOS الأصلية | 5 | بيانات Mach-O والتوقيعات وملفات plist والمعماريات وفك رموز Swift دون تشغيل Hopper | +| رسم القطع الأثرية | 3 | جرد حتمي للدلائل وZIP/APK/IPA وASAR واستخراج معاملاتي محدد صراحة | +| Managed PE/CLI | 8 | هوية PE/CLI، أعضاء البيانات الوصفية، بصمات CIL، تصريحات P/Invoke/الحدود الأصلية والتحقق منها، إسقاط رسم التطبيق، استيراد إعادة البناء من فك الترجمة، إعادة ربط الرموز، خطط ترابط وقت التشغيل ومقارنة الإصدارات | +| مراقبة المتصفح | 9 | التقاط CDP محدود الأصل وتحليل الحزم وخرائط المصدر واكتشاف WebMCP والفروق والأدلة المرئية | +| تحليل Electron | 4 | مراقبة سلبية ضمن جذور canonical، ورسم ثابت ومحدود للتطبيق، ومطابقة ثابتة/وقت تشغيل قائمة على Evidence | +| وقت تشغيل JavaScript | 2 | اكتشاف أهداف Node/Electron Inspector المعتمدة للاتصال فقط، ومراقبة محدودة للبرامج النصية وسياقات التنفيذ دون تقييم أو تجهيز | +| سير عمل التطبيقات | 12 | تتبع محدود ومقارنة إصدارات فريدة ومقارنة ثابتة لأشكال قيم إرجاع الصادرات وإعادة تشغيل وحدات معزولة ومعتمدة على Linux وتوصيف وقت التشغيل المُدار وإغلاق تغطية إعادة البناء ودفاتر التزامات حتمية والتحقق الشامل من جاهزية إعادة البناء | +| جلسة الملف التنفيذي | 19 | فتح الأهداف وحزم الأدلة وتشغيل آلة إعادة محدودة ومقارنة العمليات والقطع والدوال وسجل المجهولات المتبقية | ## استخدام REA مع وكلاء برمجة آخرين diff --git a/README_ja.md b/README_ja.md index 5c68b6053..d7d814ed2 100644 --- a/README_ja.md +++ b/README_ja.md @@ -149,17 +149,18 @@ REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェ ## 調査ツールカタログ -| ツール群 | 数 | 例 | -| ---------------------- | --: | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| バイナリ調査 | 34 | プロシージャ、疑似コード、アセンブリ、文字列、名前、セグメント、callers、callees、xrefs、注釈 | -| 合成解析 | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift / ObjC 検出 | -| macOS ネイティブ | 5 | Mach-O メタデータ、署名、plist、アーキテクチャ、Swift デマングル。Hopper 起動不要 | -| アーティファクトグラフ | 2 | ディレクトリ、ZIP/APK/IPA、ASAR の決定的一覧と明示選択されたトランザクション抽出 | -| Managed PE/CLI | 8 | PE/CLI 識別、メタデータメンバー、CIL ハッシュ、P/Invoke/ネイティブ境界宣言と検証、アプリケーショングラフ投影、逆コンパイル再構成インポート、構造的 token 再マッピング、ランタイム相関計画、バージョン比較 | -| ブラウザ観察 | 8 | origin 限定 CDP 取得、bundle/source map 解析、WebMCP 検出、セッション、capture diff、視覚証拠 | -| Electron 解析 | 4 | canonical ファイルルート内の受動観察、有界な静的アプリマッピング、Evidence に基づく静的/実行時の照合 | -| アプリケーション | 9 | 有界なクロスレイヤー追跡、一意限定のバージョン比較、静的なエクスポート戻り値形状比較、承認済み Linux 隔離 extracted-module replay、マネージドランタイム特性評価、再構築カバレッジ閉包 | -| バイナリセッション | 19 | `open_binary`、`binary_session`、証拠バンドル、有限リプレイ状態機械、プロセス/成果物/関数比較、残余未知レジストリ | +| ツール群 | 数 | 例 | +| ---------------------- | --: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| バイナリ調査 | 36 | プロシージャ、疑似コード、アセンブリ、文字列、名前、セグメント、callers、callees、xrefs、注釈 | +| 合成解析 | 12 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift / ObjC 検出 | +| macOS ネイティブ | 5 | Mach-O メタデータ、署名、plist、アーキテクチャ、Swift デマングル。Hopper 起動不要 | +| アーティファクトグラフ | 3 | ディレクトリ、ZIP/APK/IPA、ASAR の決定的一覧と明示選択されたトランザクション抽出 | +| Managed PE/CLI | 8 | PE/CLI 識別、メタデータメンバー、CIL ハッシュ、P/Invoke/ネイティブ境界宣言と検証、アプリケーショングラフ投影、逆コンパイル再構成インポート、構造的 token 再マッピング、ランタイム相関計画、バージョン比較 | +| ブラウザ観察 | 9 | origin 限定 CDP 取得、bundle/source map 解析、WebMCP 検出、セッション、capture diff、視覚証拠 | +| Electron 解析 | 4 | canonical ファイルルート内の受動観察、有界な静的アプリマッピング、Evidence に基づく静的/実行時の照合 | +| JavaScript ランタイム | 2 | 承認済みのアタッチ専用 Node/Electron Inspector ターゲット検出と、評価や計装を行わない有界なスクリプト/実行コンテキスト観察 | +| アプリケーション | 12 | 有界なクロスレイヤー追跡、一意限定のバージョン比較、静的なエクスポート戻り値形状比較、承認済み Linux 隔離 extracted-module replay、マネージドランタイム特性評価、再構築カバレッジ閉包、決定論的な義務台帳、エンドツーエンドの準備適合性検証 | +| バイナリセッション | 19 | `open_binary`、`binary_session`、証拠バンドル、有限リプレイ状態機械、プロセス/成果物/関数比較、残余未知レジストリ | ## 他のコーディングエージェントで使う diff --git a/README_ko.md b/README_ko.md index 6709261d3..647eabb46 100644 --- a/README_ko.md +++ b/README_ko.md @@ -149,17 +149,18 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이 ## 조사 도구 카탈로그 -| 도구 그룹 | 수 | 예시 | -| ------------------- | --: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 바이너리 검사 | 34 | 프로시저, 의사 코드, 어셈블리, 문자열, 이름, 세그먼트, callers, callees, xrefs, 주석 | -| 합성 분석 | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift 및 ObjC 탐색 | -| macOS 네이티브 도구 | 5 | Mach-O 메타데이터, 코드 서명, plist, 아키텍처, Swift 디맹글링. Hopper 실행 불필요 | -| 아티팩트 그래프 | 2 | 디렉터리, ZIP/APK/IPA, ASAR 결정적 인벤토리와 명시적으로 선택한 트랜잭션 추출 | -| Managed PE/CLI | 8 | PE/CLI ID, 메타데이터 멤버, CIL 해시, P/Invoke/네이티브 경계 선언 및 검증, 애플리케이션 그래프 투영, 디컴파일 재구성 가져오기, 구조적 token 재매핑, 런타임 상관 계획, 버전 비교 | -| 브라우저 관찰 | 8 | origin 제한 CDP 캡처, bundle/source map 분석, WebMCP 검색, 세션 타임라인, capture diff 및 시각 증거 | -| Electron 분석 | 4 | canonical 파일 루트 내 수동 관찰, 제한된 정적 앱 매핑, Evidence 기반 정적/런타임 조정 | -| 애플리케이션 | 9 | 제한된 교차 계층 추적, 고유 일치 버전 비교, 정적 export 반환 형태 비교, 승인된 Linux 격리 extracted-module replay, 관리형 런타임 특성화, 재구성 커버리지 폐쇄 | -| 바이너리 세션 | 19 | `open_binary`, `binary_session`, 증거 번들, 유한 replay 상태 머신, 프로세스·아티팩트·함수 비교, 잔여 미지수 레지스트리 | +| 도구 그룹 | 수 | 예시 | +| ------------------- | --: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 바이너리 검사 | 36 | 프로시저, 의사 코드, 어셈블리, 문자열, 이름, 세그먼트, callers, callees, xrefs, 주석 | +| 합성 분석 | 12 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift 및 ObjC 탐색 | +| macOS 네이티브 도구 | 5 | Mach-O 메타데이터, 코드 서명, plist, 아키텍처, Swift 디맹글링. Hopper 실행 불필요 | +| 아티팩트 그래프 | 3 | 디렉터리, ZIP/APK/IPA, ASAR 결정적 인벤토리와 명시적으로 선택한 트랜잭션 추출 | +| Managed PE/CLI | 8 | PE/CLI ID, 메타데이터 멤버, CIL 해시, P/Invoke/네이티브 경계 선언 및 검증, 애플리케이션 그래프 투영, 디컴파일 재구성 가져오기, 구조적 token 재매핑, 런타임 상관 계획, 버전 비교 | +| 브라우저 관찰 | 9 | origin 제한 CDP 캡처, bundle/source map 분석, WebMCP 검색, 세션 타임라인, capture diff 및 시각 증거 | +| Electron 분석 | 4 | canonical 파일 루트 내 수동 관찰, 제한된 정적 앱 매핑, Evidence 기반 정적/런타임 조정 | +| JavaScript 런타임 | 2 | 승인된 연결 전용 Node/Electron Inspector 대상 탐색과 평가·계측 없는 제한된 스크립트 및 실행 컨텍스트 관찰 | +| 애플리케이션 | 12 | 제한된 교차 계층 추적, 고유 일치 버전 비교, 정적 export 반환 형태 비교, 승인된 Linux 격리 extracted-module replay, 관리형 런타임 특성화, 재구성 커버리지 폐쇄, 결정론적 의무 원장, 엔드투엔드 준비 적합성 검증 | +| 바이너리 세션 | 19 | `open_binary`, `binary_session`, 증거 번들, 유한 replay 상태 머신, 프로세스·아티팩트·함수 비교, 잔여 미지수 레지스트리 | ## 다른 코딩 에이전트에서 사용하기 diff --git a/README_zh.md b/README_zh.md index c621bf066..62e6048f9 100644 --- a/README_zh.md +++ b/README_zh.md @@ -149,17 +149,18 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常 ## 调查工具目录 -| 工具类别 | 数量 | 示例 | -| -------------- | ---: | --------------------------------------------------------------------------------------------------------------------------------------------- | -| 二进制检查 | 34 | 过程、伪代码、汇编、字符串、名称、段、调用者、被调用者、交叉引用、注释 | -| 组合分析 | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`、Swift 与 ObjC 发现 | -| macOS 原生工具 | 5 | Mach-O 元数据、代码签名、plist、架构与 Swift 符号还原,无需启动 Hopper | -| 制品图谱 | 2 | 确定性目录、ZIP/APK/IPA 与 ASAR 清单,以及显式选择的事务式提取 | -| Managed PE/CLI | 8 | PE/CLI 身份、元数据成员、CIL 哈希、P/Invoke/原生边界声明与验证、应用图谱投影、反编译重建导入、结构化 token 重映射、运行时关联计划与跨版本比较 | -| 浏览器观察 | 8 | 精确来源 CDP 抓取、bundle/source map 分析、WebMCP 发现、会话时间线、capture diff 与视觉证据 | -| Electron 分析 | 4 | 限定 canonical 文件根的被动观察、有界静态应用映射,以及基于 Evidence 的静态/运行时对账 | -| 应用工作流 | 9 | 有界跨层追踪、唯一匹配跨版本图比较、静态导出返回形状比较、经批准的 Linux 隔离 extracted-module replay、托管运行时表征和重建覆盖闭合 | -| 二进制会话 | 19 | `open_binary`、`binary_session`、证据包、有限重放状态机、进程、制品与函数比较、残余未知项注册表 | +| 工具类别 | 数量 | 示例 | +| ----------------- | ---: | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 二进制检查 | 36 | 过程、伪代码、汇编、字符串、名称、段、调用者、被调用者、交叉引用、注释 | +| 组合分析 | 12 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`、Swift 与 ObjC 发现 | +| macOS 原生工具 | 5 | Mach-O 元数据、代码签名、plist、架构与 Swift 符号还原,无需启动 Hopper | +| 制品图谱 | 3 | 确定性目录、ZIP/APK/IPA 与 ASAR 清单,以及显式选择的事务式提取 | +| Managed PE/CLI | 8 | PE/CLI 身份、元数据成员、CIL 哈希、P/Invoke/原生边界声明与验证、应用图谱投影、反编译重建导入、结构化 token 重映射、运行时关联计划与跨版本比较 | +| 浏览器观察 | 9 | 精确来源 CDP 抓取、bundle/source map 分析、WebMCP 发现、会话时间线、capture diff 与视觉证据 | +| Electron 分析 | 4 | 限定 canonical 文件根的被动观察、有界静态应用映射,以及基于 Evidence 的静态/运行时对账 | +| JavaScript 运行时 | 2 | 经批准的只附加 Node/Electron Inspector 目标发现,以及不执行求值或插桩的有界脚本和执行上下文观察 | +| 应用工作流 | 12 | 有界跨层追踪、唯一匹配跨版本图比较、静态导出返回形状比较、经批准的 Linux 隔离 extracted-module replay、托管运行时表征、重建覆盖闭合、确定性义务账本和端到端就绪性一致性验证 | +| 二进制会话 | 19 | `open_binary`、`binary_session`、证据包、有限重放状态机、进程、制品与函数比较、残余未知项注册表 | ## 与其他编程智能体一起使用 diff --git a/bridge/hopper_bridge.py b/bridge/hopper_bridge.py index 1a9d03c00..249d7ce1b 100644 --- a/bridge/hopper_bridge.py +++ b/bridge/hopper_bridge.py @@ -11,20 +11,86 @@ import os import re import sre_parse import socket +from typing import Any, Optional, Protocol, Sequence MAX_LINE_BYTES = 10 * 1024 * 1024 +MAX_READ_BYTES = 4096 BAD_ADDRESSES = (-1, 0xFFFFFFFFFFFFFFFF, None) _selected_document = None _search_inventory_cache = {} _pseudocode_cache = {} +_hopper_api = None MAX_SEARCH_PATTERN_LENGTH = 256 MAX_SEARCH_VALUE_LENGTH = 4096 +class HopperDocument(Protocol): + """Typed minimum used by the provider-neutral Hopper API facade.""" + + def getDocumentName(self) -> str: ... + + def getExecutableFilePath(self) -> Optional[str]: ... + + def getDatabaseFilePath(self) -> Optional[str]: ... + + def backgroundProcessActive(self) -> bool: ... + + +class HopperDocumentProvider(Protocol): + """Hopper's injected global document provider.""" + + @classmethod + def getAllDocuments(cls) -> Sequence[HopperDocument]: ... + + @classmethod + def getCurrentDocument(cls) -> Optional[HopperDocument]: ... + + +class HopperApiFacade: + """Small injected boundary around Hopper globals for standalone tests.""" + + def __init__(self, document_provider: HopperDocumentProvider): + self._document_provider = document_provider + + def documents(self) -> Sequence[HopperDocument]: + return self._document_provider.getAllDocuments() + + def current_document(self) -> Optional[HopperDocument]: + return self._document_provider.getCurrentDocument() + + def require_analysis_complete( + self, document: HopperDocument, operation: str + ) -> None: + if document.backgroundProcessActive(): + raise CapabilityUnavailableError( + "%s requires completed Hopper background analysis" % operation + ) + + +def _configure_hopper_api(document_provider): + """Inject Hopper's document provider or a standalone test facade.""" + global _hopper_api + _hopper_api = HopperApiFacade(document_provider) + + +def _api(): + """Resolve Hopper globals lazily so importing this module needs no Hopper.""" + global _hopper_api + if _hopper_api is not None: + return _hopper_api + document_provider = globals().get("Document") + if document_provider is None: + raise CapabilityUnavailableError( + "Hopper Document API is unavailable outside the Hopper runtime" + ) + _hopper_api = HopperApiFacade(document_provider) + return _hopper_api + + def _session_document(): """Find only the document opened for this authenticated REA session.""" target = os.path.realpath(REA_TARGET_PATH) - for document in Document.getAllDocuments(): + for document in _api().documents(): paths = (document.getExecutableFilePath(), document.getDatabaseFilePath()) for path in paths: if path and os.path.realpath(path) == target: @@ -37,6 +103,31 @@ MAX_REGEX_CANDIDATE_LENGTH = 4096 MAX_REGEX_SEARCH_WORK_UNITS = 1000000 +class CapabilityUnavailableError(Exception): + """The active Hopper build lacks one admitted public API operation.""" + + +EXHAUSTIVE_ANALYSIS_METHODS = frozenset( + ( + "analyze_function", + "list_names", + "list_procedures", + "list_strings", + "procedure_address", + "procedure_assembly", + "procedure_callees", + "procedure_callers", + "procedure_info", + "procedure_pseudo_code", + "procedure_references", + "read_function_instructions", + "resolve_containing_procedure", + "search_procedures", + "search_strings", + ) +) + + def _hex(value): return "0x%x" % value @@ -55,7 +146,8 @@ def _json_safe(value): def _document(name=None): """Resolve an explicit or session-selected document without changing Hopper UI.""" global _selected_document - documents = Document.getAllDocuments() + api = _api() + documents = api.documents() if name is not None: for candidate in documents: if candidate.getDocumentName() == name: @@ -65,7 +157,7 @@ def _document(name=None): for candidate in documents: if candidate.getDocumentName() == _selected_document: return candidate - current = Document.getCurrentDocument() + current = api.current_document() if current is None: raise ValueError("No Hopper document is loaded") return current @@ -762,11 +854,54 @@ def _dispatch(method, params): return _selected_document document = _document(params.get("document")) + if method in EXHAUSTIVE_ANALYSIS_METHODS: + _api().require_analysis_complete(document, method) if method == "analyze_function": return _analyze_function(document, params) if method == "read_function_instructions": return _read_function_instructions(document, params) + if method == "read_bytes": + length = params.get("length", 256) + if isinstance(length, bool) or not isinstance(length, int): + raise ValueError("Byte-read length must be an integer") + if length < 1 or length > MAX_READ_BYTES: + raise ValueError("Byte-read length must be between 1 and 4096") + reader = getattr(document, "readBytes", None) + if not callable(reader): + raise CapabilityUnavailableError( + "Hopper's public Python API does not expose readBytes" + ) + address = _address(document, params.get("address")) + value = reader(address, length) + if not isinstance(value, (bytes, bytearray)): + raise CapabilityUnavailableError( + "Hopper's public Python API returned an unsupported byte representation" + ) + data = bytes(value) + return { + "address": _hex(address), + "requested_bytes": length, + "returned_bytes": len(data), + "bytes_hex": data.hex(), + "complete": len(data) == length, + } + if method == "address_to_file_offset": + mapper = getattr(document, "getFileOffsetFromAddress", None) + if not callable(mapper): + raise CapabilityUnavailableError( + "Hopper's public Python API does not expose getFileOffsetFromAddress" + ) + address = _address(document, params.get("address")) + offset = mapper(address) + if ( + isinstance(offset, bool) + or not isinstance(offset, int) + or offset in BAD_ADDRESSES + or offset < 0 + ): + raise ValueError("Address has no authoritative file-offset mapping") + return {"address": _hex(address), "file_offset": offset} if method == "resolve_containing_procedure": address = _address(document, params.get("address")) procedure, reason = _containing_procedure(document, address) @@ -935,6 +1070,8 @@ def _serve_connection(connection): def _diagnostic_type(error): + if isinstance(error, CapabilityUnavailableError): + return "capability_unavailable" if isinstance(error, PermissionError): return "authorization" if isinstance(error, (ValueError, TypeError, KeyError)): @@ -961,4 +1098,5 @@ def _run(): # Hopper's public objects are bound to its dedicated Python execution thread. # Keep dispatch on that thread; moving calls to an arbitrary worker can deadlock. -_run() +if __name__ == "__main__": + _run() diff --git a/docs/browser-observation.md b/docs/browser-observation.md index 9b4717616..3823305f3 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -5,11 +5,11 @@ REA can attach to a user-owned Chrome-family browser through the Chrome DevTools ## Shipped surfaces - `list_browser_targets` / `rea list-browser-targets` discovers page targets whose current URL matches an approved exact origin. -- `inspect_web_page` / `rea inspect-web-page` captures bounded DOM structure, accessibility nodes, scripts, resources, safe response/DOM metadata, attach-window network and console metadata, WebSocket frame sizes, workers, quota, and optionally storage key names or script sources. +- `inspect_web_page` / `rea inspect-web-page` captures bounded DOM structure, accessibility nodes, scripts, resources, safe response/DOM metadata, attach-window network and console metadata, WebSocket frame sizes, workers, quota, and optionally storage key names, redacted storage-content fingerprints, or script sources. - `analyze_web_bundle` / `rea analyze-web-bundle` parses approved script artifacts without execution and derives chunk edges, route and endpoint candidates, vendor fingerprints, static WebMCP declarations, and optionally approved source-map/original-source evidence. - `observe_web_session` / `rea observe-web-session` arms a bounded window for an external user action and records ordered reload, SPA navigation, redirect, failure, lifecycle, and target-termination metadata. - `discover_webmcp_tools` / `rea discover-webmcp-tools` uses the experimental CDP WebMCP domain to inventory page registrations. REA never exposes `WebMCP.invokeTool`. -- `compare_web_captures` / `rea compare-web-captures` compares stable DOM, script, resource, network, metadata, and optional WebMCP identities without treating incomplete absence as equivalence. +- `compare_web_captures` / `rea compare-web-captures` compares passive captures or exact-step-aligned browser scenarios. Scenario results commit reproducible literal normalization and expose alignment failures plus artifact-level action, screenshot, DOM, accessibility, URL, history, storage, and event differences. Missing or truncated evidence is never treated as equivalence. - `capture_web_screenshot` / `rea capture-web-screenshot` returns an explicitly approved, bounded, content-addressed visible-viewport PNG. - `compare_web_screenshots` / `rea compare-web-screenshots` performs bounded local PNG pixel comparison without OCR or external services. - Every surface has equivalent CLI and MCP contracts and returns Evidence v2 provenance. @@ -92,11 +92,11 @@ rea inspect-web-page http://127.0.0.1:9222 TARGET_ID \ --include-json-body-shapes --json-body-schema-approved \ --include-websocket-shapes --websocket-shape-approved \ --include-script-sources \ - --include-storage-keys \ + --include-storage-keys --include-storage-fingerprints \ --json ``` -Accessibility text and script content may contain application secrets and become part of the returned Evidence. Accessibility and console text have independent per-field and aggregate UTF-8 byte limits; credential-shaped console substrings are redacted. JSON and WebSocket captures retain paths, types, counts, and truncation only, never values or examples. Storage values remain redacted even when key-name capture is approved. +Accessibility text and script content may contain application secrets and become part of the returned Evidence. Accessibility and console text have independent per-field and aggregate UTF-8 byte limits; credential-shaped console substrings are redacted. JSON and WebSocket captures retain paths, types, counts, and truncation only, never values or examples. Storage values remain redacted even when key-name capture is approved. `--include-storage-fingerprints` separately approves SHA-256 identity/value fingerprints, which support comparison only when cookies, DOM storage, IndexedDB, and Cache Storage were all captured completely. Screenshot pixels require both `--approved` and `--screenshot-approved`: @@ -122,7 +122,8 @@ rea capture-web-screenshot http://127.0.0.1:9222 TARGET_ID \ "include_websocket_shapes": false, "websocket_shape_approved": false, "include_script_sources": false, - "include_storage_keys": false + "include_storage_keys": false, + "include_storage_fingerprints": false } ``` @@ -141,7 +142,7 @@ REA removes sensitive values before normalized event data is retained: - Request/response bodies are not requested or parsed by default. With independent approval, only allowed-origin JSON media types are read within per-body and aggregate budgets, converted immediately to value-free property paths/types, and discarded. `Network.getResponseBody` is never sent without that approval. - Console observations with an approved stack source retain call type, argument types, timestamp, and redacted source location. With independent approval, only already-delivered primitive values are retained after credential redaction and byte bounds; objects, getters, and remote properties are never expanded. - WebSocket observations retain direction, opcode, and payload byte length. With independent approval, bounded text frames are classified as text or value-free JSON shape; binary bytes, hashes, prefixes, and raw frames are never retained. -- Storage observations always redact values. Key names, IndexedDB names, and cache names are omitted unless explicitly requested. +- Storage observations always redact values. Key names, IndexedDB names, and cache names require `include_storage_keys`; stable content fingerprints require the additional `include_storage_fingerprints` approval. Cache bodies above 64 KiB and partial IndexedDB remote objects make fingerprint coverage incomplete, so identical observations remain `unknown`. - Script metadata is included only when CDP supplies a URL on an allowed origin. Stable keys exclude transient CDP script IDs, and exact transient raw URLs are used only during script/resource reconciliation. URL-less scripts are excluded because their origin cannot be established. When CDP supplies an execution-context association, the accepted script retains its authorized frame ID for later attribution. Source content is omitted unless explicitly requested and becomes a self-verifying `rea://web-content/sha256/...` artifact subject to per-script and aggregate byte limits. Cross-origin frames, resources, scripts, events, and workers are excluded unless their exact origins are also approved. Excluded target details are counted without being exposed. diff --git a/docs/browser-scenario-contract.md b/docs/browser-scenario-contract.md new file mode 100644 index 000000000..27065775d --- /dev/null +++ b/docs/browser-scenario-contract.md @@ -0,0 +1,80 @@ +# Browser scenario contract + +`capture_browser_scenario` accepts the versioned provider-neutral +`browserScenarioSchema` through both MCP and the +`capture-browser-scenario INPUT_JSON` CLI command. Version 1 is declarative and +bounded: it admits a fixed action vocabulary, exact HTTP(S) origins, +deterministic browser settings, explicit storage and request replay, and finite +capture limits. `INPUT_JSON` may be inline JSON or a JSON file path. + +The browser boundary is part of the contract. Launch mode requires a +caller-selected absolute executable, a provider-owned temporary profile, +headless operation, and close-plus-delete cleanup. Connect mode accepts only an +explicit-port loopback CDP endpoint, declares the browser externally owned, and +permits disconnect-only cleanup. The Playwright driver preserves this +distinction: it closes and deletes provider-owned profiles, but only disconnects +from an external CDP browser. Real-browser verification checks both outcomes and +confirms that an attached external browser remains alive. + +URLs contain a query-free HTTP(S) base plus ordered query entries. Every query, +form, storage, cookie, and replay value is either an explicitly public literal +or a reference to a declared environment-backed secret. Raw credentials in +URLs and literal credential headers are rejected. Secret query names must also +appear in the redaction policy, and the required credential headers cannot be +removed from that policy. Durable results must replace resolved secret values +with their secret references. + +Every start, navigation, storage, replay, and redirect origin must be listed in +`allowed_origins`. Unsupported action tags, unknown fields, duplicate step or +route IDs, undeclared or unused secrets, and provider-owned replay headers fail +validation. HTTP and WebSocket routing enforces the same declared scope. Exact +replay may abort unmatched requests or pass through only approved origins. + +The result is Evidence v2 with an initial state followed by one record per +declared action. Each step reports action status, elapsed time, sanitized URLs, +event bounds, and independently typed capture states for screenshot, DOM, +accessibility, URL, history, and storage. Console, page-error, network, +WebSocket, frame, worker, popup, and cancelled-download events are attributed to +steps and bounded by the scenario limits. Missing and truncated sections remain +explicit and make the capture ineligible for equality claims. Attach-mode +captures also declare the unavoidable pre-attach event gap. + +Browser automation is disabled by default. The administrator ceiling is +configured with: + +- `REA_BROWSER_SCENARIO_ENABLED` +- `REA_BROWSER_SCENARIO_EXECUTABLE_ROOTS_JSON` +- `REA_BROWSER_SCENARIO_CDP_ENDPOINTS_JSON` +- `REA_BROWSER_SCENARIO_ALLOWED_ORIGINS_JSON` +- `REA_BROWSER_SCENARIO_ALLOWED_ENV_JSON` + +Unlike passive observation, browser automation is not granted automatically. +Use an explicit project/session grant, or opt into the administrator grant with +`REA_BROWSER_SCENARIO_AUTO_GRANT=true` for a trusted unattended environment. +Authorization commits the exact executable or CDP endpoint, origins, environment +variable names, network class, and scenario digest before any browser side +effect. + +## Scenario comparison + +`compare_web_captures` and `compare-web-captures` also accept two complete +browser scenario captures. Steps align only by exact, unique `step_id`. +Duplicate IDs, missing counterparts, changed action kinds, and incompatible +browser/origin capture context are returned as alignment failures. They prevent +an unchanged claim; observed differences may still prove a changed result. + +The comparison records its full normalization commitment and SHA-256 digest. +Built-in rules exclude step elapsed time and event sequence/index fields, +compare screenshots by content digest, and compare DOM/accessibility captures +by normalized text. Optional caller rules are bounded exact-literal +replacements over named artifact kinds. Rules and artifact-kind lists are +sorted canonically before application, making the same policy reproducible +regardless of input order. Rules operate only on already-redacted durable +capture fields and are preserved in the result. + +Each aligned step reports changed, unchanged, or unknown. Changed and unknown +action, screenshot, DOM, accessibility, URL, history, storage, and event +artifacts carry their normalized before/after digests and capture states. +`max_changes` bounds retained artifact records while total and omitted counts +remain visible. Missing, truncated, or mismatched capture coverage remains +unknown instead of being treated as equality. diff --git a/docs/javascript-application-workflows.md b/docs/javascript-application-workflows.md index f23e1e713..371467ddc 100644 --- a/docs/javascript-application-workflows.md +++ b/docs/javascript-application-workflows.md @@ -3,8 +3,9 @@ REA derives a bounded feature trace from one authenticated JavaScript Application Graph and compares two authenticated graph versions. The MCP tools are `trace_application_feature`, `trace_javascript_semantics`, -`compare_application_versions`, and `compare_javascript_export_shapes`; their -CLI equivalents use the same names with hyphens. +`compare_application_versions`, `compare_source_to_bundle`, and +`compare_javascript_export_shapes`; their CLI equivalents use the same names +with hyphens. Both workflows consume Evidence v2 produced by `analyze_javascript_application` or `reconcile_javascript_runtime`. They do not @@ -46,14 +47,22 @@ function, module, seed-match, and page limits are validated against exact published ranges. A committed cursor returns the next deterministic relation page only for the same graph, query, and limits. -The initial extractor covers lexical definitions and reads, closure captures, -and uniquely resolved local calls with argument-to-parameter and direct -return-to-call candidates. Dynamic calls/properties, parser recovery, and bound -frontiers stay explicit. Promise, event, timer, child-process, request, -configuration, boundary, resource, and fingerprint families are represented by -the versioned schema but report unsupported or unknown coverage until their -extractors supply facts. Missing relations in those families are never reported -as proven absence. +The extractor covers lexical definitions and reads, literal seeds, static +property slots, object reads/writes/spreads/destructuring, closure captures, +uniquely resolved local calls, argument/return flow, and explicit Promise +construction, chaining, aggregation, await, return, assignment, and +detachment. It also recovers bounded static candidates for EventEmitter +registrations/removals and dispatch, Node timers and cancellation handles, asynchronous +`node:child_process` creation and ownership, configuration sources and direct +defaults, request construction and response consumers, parse/coercion/ +validation boundaries, and built-in resource acquisition/release. + +Function fingerprints commit normalized syntax, control-flow shape, relation +shape, literal sets, arity, and detected effects without using local names or +source offsets. Equal duplicate fingerprints remain ambiguous. Dynamic +calls/properties/names, parser recovery, unresolved handles, unsupported API +shapes, and every reached bound stay explicit unknowns; a missing relation is +never reported as proven behavioral absence. Semantic relations use static-inference authority. `resolved` means the static target identity was unique within admitted analysis; it does not mean the path @@ -83,6 +92,25 @@ items, candidate references, graph nodes, edges, and observations. With `unknown_registry_approved: true`, unresolved comparison items can be retained as a residual unknown in a live session. +## Historical source-to-bundle comparison + +`compare_source_to_bundle` compares one cryptographically committed +`HistoricalSourceGraph/v1` with authenticated application-graph Evidence. The +stable scoring model reports every admitted signal and weight: exact source +digest, source-map original path, exact current path, path suffix, basename, and +language extension. Exact digest wins over location inference; weak basename +or extension evidence never forces a mapping. + +Each historical source file is classified as `unchanged`, `modified`, +`removed`, `split`, `merged`, `duplicated`, or `unknown`. Multiple equally +scored path mappings form a bounded static split inference. Multiple current +nodes with the exact historical digest are duplicated; multiple historical +paths with exact identity to one current node are merged. `removed` requires +complete historical and application inventories with no relevant comparison +truncation. Ambiguous candidates, partial inventories, and exhausted limits +remain unknown. These static mappings do not claim runtime loading or semantic +equivalence. + ## Export return-shape comparison `compare_javascript_export_shapes` selects exactly one module path and export @@ -108,7 +136,7 @@ replay recommendation; it does not execute JavaScript. ## CLI and verification -All four CLI commands accept inline JSON or a JSON file up to 64 MiB. The input +All five CLI commands accept inline JSON or a JSON file up to 64 MiB. The input is the same object used by the corresponding MCP tool. For two operator-provided directories or ASARs, run: diff --git a/docs/javascript-runtime-observation.md b/docs/javascript-runtime-observation.md new file mode 100644 index 000000000..39ab5a48d --- /dev/null +++ b/docs/javascript-runtime-observation.md @@ -0,0 +1,101 @@ +# Passive Node and Electron runtime observation + +REA can attach to an already-running Node.js or Electron V8 Inspector target +and retain bounded script-load and execution-context metadata. The +`list_javascript_runtime_targets` and `observe_javascript_runtime` MCP tools +have equivalent `rea list-javascript-runtime-targets` and +`rea observe-javascript-runtime` commands. Successful calls return +deterministic Evidence v2. + +This authority is disabled by default and is separate from browser CDP, +Electron file-page inspection, Process Capture, and controlled JavaScript +replay: + +```bash +export REA_V8_INSPECTOR_OBSERVE_ENABLED=true +export REA_V8_INSPECTOR_ENDPOINTS_JSON='["http://127.0.0.1:9229"]' +export REA_V8_INSPECTOR_FILE_ROOTS_JSON='["/absolute/path/to/app"]' +export REA_V8_INSPECTOR_ALLOWED_ORIGINS_JSON='[]' + +rea list-javascript-runtime-targets http://127.0.0.1:9229 \ + --approved --json +rea observe-javascript-runtime http://127.0.0.1:9229 TARGET_ID \ + --runtime-kind node --approved --json +``` + +At least one canonical file root or exact HTTP(S) origin is required. +Discovery is restricted to a literal-loopback HTTP endpoint. A target must +match the exact requested target ID, expose a same-port WebSocket reported by +the approved discovery endpoint, and remain inside the requested file or +origin scope. Targets already marked attached are rejected rather than +displacing another debugger. Canonical file paths are checked after symlink resolution. +Excluded locations are counted but never retained. + +## Passive protocol boundary + +The provider sends exactly two protocol commands: + +- `Runtime.enable` +- `Debugger.enable` + +It never sends `Runtime.evaluate`, reads script source, installs breakpoints, +pauses or resumes execution, calls `Runtime.runIfWaitingForDebugger`, launches +the target, instruments JavaScript, or invokes Electron APIs. Closing a call +closes only REA's WebSocket. + +The direct observations are: + +- `Debugger.scriptParsed`, establishing that a bounded script location was + parsed in the observed execution context; +- `Debugger.scriptFailedToParse`, retained only as a bounded invalid-script + count; +- `Runtime.executionContextCreated`, + `Runtime.executionContextDestroyed`, and + `Runtime.executionContextsCleared`, establishing context lifecycle within + the capture window. + +The Inspector protocol does not directly expose require/import caller edges, +EventEmitter emissions or listener invocation, Electron IPC messages or +handlers, or script-unload events. Those facts remain explicit unknowns. +`scriptParsed` proves script presence, not the importing module, feature +execution, initialization order, or causality. + +## Node and Electron roles + +The caller declares one role: `node`, `electron-main`, `electron-preload`, or +`electron-renderer`. Node and Electron-main declarations require a protocol +target of type `node`; preload and renderer declarations require `page`. +Inspector does not authenticate the operating-system PID or distinguish an +Electron role, so Evidence records the role authority as +`caller-declared-unverified`. + +Electron main, preload, and renderer behavior can therefore be observed only +as separate approved Inspector targets. The provider does not infer that two +targets belong to the same Electron application. + +## Bounds and determinism + +Every observation commits the window and exact limits for events, scripts, +execution contexts, per-location bytes, and total retained metadata bytes. +Limit hits increment dropped-event counts and set `capture.truncated`; they +never become completeness claims. Scripts are authorized after capture, +deduplicated by stable metadata, and canonically sorted. Wall-clock timestamps +and protocol script IDs are excluded from the durable result, so identical +inputs and captured metadata produce the same Evidence ID. + +Inspector attach is inherently incomplete. Enabling `Debugger` reports known +and uncollected scripts, but scripts collected before attachment may be +missing. Absence from a bounded capture never proves that a script or behavior +does not occur. + +## Static correlation + +Pass the resulting observation Evidence and an +`analyze_javascript_application` Evidence record to +`reconcile_javascript_runtime`. The reconciliation accepts this provider +alongside passive web and Electron page captures. Exact approved file or URL +mappings can correlate script presence with JavaScript Application Graph +assets. Because this provider never reads source bytes, matches normally use a +unique authorized location and remain weaker than captured-byte identity. +Builtin `node:` scripts remain runtime-only nodes unless a future explicit +static authority models them. diff --git a/docs/javascript-runtime-reconciliation.md b/docs/javascript-runtime-reconciliation.md index 00c0f0a88..80dfa7536 100644 --- a/docs/javascript-runtime-reconciliation.md +++ b/docs/javascript-runtime-reconciliation.md @@ -1,7 +1,7 @@ # JavaScript static/runtime reconciliation REA can combine an existing static JavaScript Application Graph with one or -more passive browser or Electron captures without collapsing their different +more passive browser, Electron page, or Node/Electron V8 Inspector captures without collapsing their different authorities. The `reconcile_javascript_runtime` MCP tool and `rea reconcile-javascript-runtime` CLI command accept Evidence v2 records and return derived Evidence v2 containing a combined JavaScript Application Graph @@ -9,7 +9,8 @@ and explicit match, ambiguity, mismatch, and unknown classifications. The operation performs no filesystem, browser, or Electron I/O. It accepts only semantically verified Evidence produced by `analyze_javascript_application`, -`inspect_web_page`, or `inspect_electron_page`; matching operation names alone +`inspect_web_page`, `inspect_electron_page`, or +`observe_javascript_runtime`; matching operation names alone is insufficient. Provider identity, predicate version, subject digest, target parameters, and normalized-result schemas are checked before reconciliation. @@ -18,12 +19,13 @@ parameters, and normalized-result schemas are checked before reconciliation. 1. Run `analyze_javascript_application` against exactly one application layer. Optional cache or assets directories can be analyzed independently and supplied as additional layers. -2. Run `inspect_web_page` or `inspect_electron_page`. Capturing script sources +2. Run `inspect_web_page`, `inspect_electron_page`, or the attach-only + `observe_javascript_runtime`. Capturing script sources is optional, but an approved source capture provides the strongest byte identity. 3. Pass the resulting Evidence records to `reconcile_javascript_runtime`. Browser URLs normally need an explicit URL-prefix mapping. Extracted - Electron directories are mapped automatically when runtime files remain + Electron and V8 Inspector file locations are mapped automatically when runtime files remain beneath the analyzed input root; relocated files, ASAR projections, caches, and asset roots need explicit mappings. @@ -134,12 +136,19 @@ globally unloaded. ## Frames, workers, and source maps -Passive inspection now retains the execution-context frame ID supplied by CDP +Passive inspection retains the execution-context frame ID supplied by CDP for each accepted script. Electron captures also inventory authorized worker, service-worker, and shared-worker targets, including bounded opener-target and parent-frame relationships. These fields improve attribution while preserving the original exact-origin or canonical-root filter. +V8 Inspector observations expose execution-context IDs but no authenticated +frame relationship, so their scripts attach to the exact runtime target in the +combined graph. They never supply source bytes, require/import caller edges, +EventEmitter activity, Electron IPC, or script-unload evidence. A unique +authorized file or URL location can still correlate script presence with a +static asset, but it does not prove module initialization or feature execution. + Source-map declarations and approved original-source reads stay under static source-map authority. They are reported in `source_map_authority` but never used as primary runtime byte matches. A source name or `sourcesContent` digest is not diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 45cca53e1..26f12c902 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -11,8 +11,14 @@ expected package version, catalog digest, or registered server path to reports their command vectors as aligned, stale, missing, or invalid, and keeps live-server state `unknown` unless the active connection supplies identity. -Tool names remain stable. `binary_session.tool_availability` explains each -operation with a stable availability reason instead of silently hiding it. +Canonical tool names remain stable, while `tools/list` advertises only the +operations callable for the current target, provider, policy, host, and +negotiated client capabilities. `binary_session.tool_availability` remains the +complete inventory: it explains advertised and hidden operations with stable +availability reasons and remediation. Each entry also reports required and +optional negotiated client features plus the currently missing features. Form elicitation is optional for +`capture_process_scenario`: an existing grant remains usable without it, while +a missing grant cannot be elicited by a client that does not advertise it. Opening or closing a target, reloading policy, or observing a provider health transition emits `notifications/tools/list_changed`. diff --git a/docs/product-catalog.json b/docs/product-catalog.json index 598b4628d..2df12a468 100644 --- a/docs/product-catalog.json +++ b/docs/product-catalog.json @@ -11,14 +11,15 @@ "skill_version": "23" }, "tools": { - "total": 99, + "total": 110, "families": [ { "id": "direct", "surface": "official-proxy", - "count": 34, + "count": 36, "tools": [ "address_name", + "address_to_file_offset", "comment", "current_address", "current_document", @@ -40,6 +41,7 @@ "procedure_info", "procedure_pseudo_code", "procedure_references", + "read_bytes", "read_function_instructions", "resolve_containing_procedure", "search_procedures", @@ -57,17 +59,19 @@ { "id": "enhanced", "surface": "enhanced", - "count": 10, + "count": 12, "tools": [ "analyze_function", "analyze_swift_types", "batch_decompile", "binary_overview", + "find_code_for_string", "find_xrefs_to_name", "get_call_graph", "get_objc_classes", "get_objc_protocols", "swift_classes", + "trace_call_path", "trace_feature" ] }, @@ -86,8 +90,8 @@ { "id": "artifact", "surface": "artifact-provider", - "count": 2, - "tools": ["extract_artifact", "inventory_artifact"] + "count": 3, + "tools": ["extract_artifact", "inspect_artifact", "inventory_artifact"] }, { "id": "managed", @@ -107,9 +111,10 @@ { "id": "browser", "surface": "browser-provider", - "count": 8, + "count": 9, "tools": [ "analyze_web_bundle", + "capture_browser_scenario", "capture_web_screenshot", "compare_web_captures", "compare_web_screenshots", @@ -130,14 +135,26 @@ "reconcile_javascript_runtime" ] }, + { + "id": "javascript-runtime", + "surface": "runtime-provider", + "count": 2, + "tools": [ + "list_javascript_runtime_targets", + "observe_javascript_runtime" + ] + }, { "id": "application", "surface": "application-workflow", - "count": 9, + "count": 12, "tools": [ + "build_reconstruction_obligation_ledger", "commit_reconstruction_coverage", "compare_application_versions", "compare_javascript_export_shapes", + "compare_source_to_bundle", + "evaluate_reconstruction_readiness", "execute_node_characterization", "prepare_node_characterization", "query_reconstruction_coverage", @@ -207,6 +224,7 @@ "version": null, "capabilities": [ "address_name", + "address_to_file_offset", "analyze_function", "comment", "current_address", @@ -229,6 +247,7 @@ "procedure_info", "procedure_pseudo_code", "procedure_references", + "read_bytes", "read_function_instructions", "resolve_containing_procedure", "search_procedures", @@ -259,7 +278,11 @@ "id": "rea-artifact-graph", "name": "REA safe artifact graph provider", "version": "1", - "capabilities": ["extract_artifact", "inventory_artifact"] + "capabilities": [ + "extract_artifact", + "inspect_artifact", + "inventory_artifact" + ] }, { "id": "rea-cdp-browser", @@ -315,9 +338,12 @@ "name": "REA JavaScript application workflows", "version": "1", "capabilities": [ + "build_reconstruction_obligation_ledger", "commit_reconstruction_coverage", "compare_application_versions", "compare_javascript_export_shapes", + "compare_source_to_bundle", + "evaluate_reconstruction_readiness", "execute_node_characterization", "prepare_node_characterization", "query_reconstruction_coverage", @@ -331,6 +357,21 @@ "name": "REA JavaScript runtime reconciliation", "version": "1", "capabilities": ["reconcile_javascript_runtime"] + }, + { + "id": "rea-playwright-browser-scenario", + "name": "REA Playwright browser scenario capture provider", + "version": "1", + "capabilities": ["capture_browser_scenario"] + }, + { + "id": "rea-v8-inspector", + "name": "REA passive Node/Electron V8 Inspector provider", + "version": "1", + "capabilities": [ + "list_javascript_runtime_targets", + "observe_javascript_runtime" + ] } ], "setup_clients": [ @@ -390,6 +431,18 @@ "id": "artifact_graph", "version": 1 }, + { + "id": "browser_scenario", + "version": 1 + }, + { + "id": "browser_scenario_capture", + "version": 1 + }, + { + "id": "browser_scenario_diff", + "version": 1 + }, { "id": "browser_target_list", "version": 1 @@ -446,6 +499,14 @@ "id": "javascript_feature_trace", "version": 1 }, + { + "id": "javascript_runtime_observation", + "version": 1 + }, + { + "id": "javascript_runtime_target_list", + "version": 1 + }, { "id": "javascript_semantic_graph", "version": 1 @@ -520,12 +581,14 @@ } ], "cli": { - "primary_count": 59, + "primary_count": 66, "commands": [ "analyze", "analyze-javascript-application", "analyze-web-bundle", + "build-reconstruction-obligation-ledger", "capabilities", + "capture-browser-scenario", "capture-process", "capture-web-screenshot", "commit-reconstruction-coverage", @@ -534,12 +597,14 @@ "compare-javascript-export-shapes", "compare-managed-members", "compare-process-captures", + "compare-source-to-bundle", "compare-web-captures", "compare-web-screenshots", "decompile", "demangle-swift", "discover-webmcp-tools", "doctor", + "evaluate-reconstruction-readiness", "evidence-export", "evidence-import", "execute-node-characterization", @@ -548,6 +613,7 @@ "import-managed-reconstruction", "import-reference-source", "inspect", + "inspect-artifact", "inspect-electron-page", "inspect-macho", "inspect-managed-artifact", @@ -562,6 +628,8 @@ "list-architectures", "list-browser-targets", "list-electron-targets", + "list-javascript-runtime-targets", + "observe-javascript-runtime", "observe-web-session", "plan-managed-runtime-correlation", "policy", @@ -591,17 +659,17 @@ }, "runtime_catalog": { "counts": { - "cli_commands": 59, - "mcp_tools": 99, + "cli_commands": 66, + "mcp_tools": 110, "mcp_prompts": 6, "mcp_resources": 2, - "mcp_resource_templates": 9 + "mcp_resource_templates": 11 }, "digests": { - "tools_sha256": "9159861cda3cc76f876dde306efae886dd310419d4e972f89ab7b85ff5789485", + "tools_sha256": "9a033ddd40fcb8273365cc895bbf75d943bf1c5c8c2ee1eb9c4b3494064aaf58", "prompts_sha256": "f6da3ad38a1000c11364d1710f122ac4990551f04efd8c53ffb2e819b5cf5707", - "resources_sha256": "94bebbab1a1b3cf47644c38d3de6469de568caa7bd7b842f529ead8ad320a912", - "combined_sha256": "e787c57af8337f17b9da72745fcb328634af3b39be1d9ca8bfe1836274351f92" + "resources_sha256": "339a984ea8cd8be5f234adf24395bf0295258d990492005cf6fe1f293d12cd46", + "combined_sha256": "e8cac51f2f17a5b9a088c3ff773473c4897823401481fc0875a28fe7daf8eee0" } } } diff --git a/docs/reconstruction-obligation-ledgers.md b/docs/reconstruction-obligation-ledgers.md new file mode 100644 index 000000000..3f682e78e --- /dev/null +++ b/docs/reconstruction-obligation-ledgers.md @@ -0,0 +1,333 @@ +# Reconstruction obligation ledgers + +`build_reconstruction_obligation_ledger` turns authenticated Evidence v2 records +into a versioned, deterministic list of reconstruction claims. The same workflow +is available as: + +- MCP tool: `build_reconstruction_obligation_ledger` +- CLI command: `rea build-reconstruction-obligation-ledger` +- MCP resource: + `rea://evidence/{evidenceId}/reconstruction-obligation-ledger` + +The ledger is conservative by design. Static Application Graph facts create +candidate obligations; they do not prove runtime or process behavior. A required +obligation closes only when one manifest binding supplies a unique owner, any +required parser/schema/domain type, every required case fixture, and a passing +verifier whose authority is comparable to the original observation. The +verifier must enumerate the obligation ID and its result must be present in the +input Evidence bundle. Contradictions, duplicate definitions or owners, +residual unknowns, missing dependencies, and unavailable authority keep closure +open or failed. + +## Complete CLI request + +This empty request is useful for checking client integration. It is valid and +returns an `unknown` ledger with zero obligations; absence of source Evidence +never claims closure: + +```sh +rea build-reconstruction-obligation-ledger \ + '{ + "evidence_bundle": { + "bundle_version": 2, + "artifacts": [], + "providers": [], + "environments": [], + "scenarios": [], + "captures": [], + "unknowns": [], + "records": [] + }, + "reviewed_obligations": [], + "manifest": { + "schema_version": 1, + "bindings": [], + "contradictions": [] + }, + "limits": { "max_obligations": 100 }, + "page": { "offset": 0, "limit": 50 } + }' \ + --json +``` + +For a real investigation, export an Evidence bundle from the session and replace +the empty bundle. Application Graph and Process Capture v4 records generate +conservative candidates automatically. Add reviewed obligations when a required +claim is not represented by those sources. + +## Reviewed obligation examples + +Each example below is a complete `reviewed_obligations` element. Replace the +sample Evidence ID with an authenticated review record already present in +`evidence_bundle.records`. Stable IDs and versions make changes reviewable. + +### CLI parsing + +```json +{ + "obligation_id": "cli.config.invalid-option", + "obligation_version": 1, + "title": "Reject an invalid configuration option with the supported CLI error", + "application_layer": "cli", + "family": "argument-parsing", + "target": { + "artifact_sha256": null, + "application_node_id": null, + "semantic_node_id": null, + "location": "rea --unknown-option" + }, + "required": true, + "required_case_kinds": ["positive", "negative", "malformed"], + "required_original_authority": "process", + "required_fixture_authority": "packaged-process", + "required_verifier_authority": "packaged-process", + "requires_parser_type": true, + "dependency_obligation_ids": [], + "residual_unknown_ids": [], + "unavailable_authority": [], + "required_next_evidence": ["Capture the installed CLI for all three cases."], + "disposition": "active", + "review_evidence_ids": [ + "ev_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ] +} +``` + +### Protocol handler + +```json +{ + "obligation_id": "protocol.rpc.cancel", + "obligation_version": 1, + "title": "Cancel an in-flight request and emit one terminal response", + "application_layer": "protocol", + "family": "request-response", + "target": { + "artifact_sha256": null, + "application_node_id": null, + "semantic_node_id": null, + "location": "JSON-RPC request cancellation" + }, + "required": true, + "required_case_kinds": ["positive", "negative", "malformed", "cancellation"], + "required_original_authority": "external", + "required_fixture_authority": "protocol", + "required_verifier_authority": "protocol", + "requires_parser_type": true, + "dependency_obligation_ids": [], + "residual_unknown_ids": [], + "unavailable_authority": [], + "required_next_evidence": [ + "Record request, cancellation, and terminal response IDs." + ], + "disposition": "active", + "review_evidence_ids": [ + "ev_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ] +} +``` + +### Electron startup and background behavior + +```json +{ + "obligation_id": "electron.background.startup", + "obligation_version": 1, + "title": "Start the background process before exposing the renderer bridge", + "application_layer": "electron", + "family": "startup", + "target": { + "artifact_sha256": null, + "application_node_id": null, + "semantic_node_id": null, + "location": "main process startup" + }, + "required": true, + "required_case_kinds": ["positive", "negative", "teardown"], + "required_original_authority": "runtime", + "required_fixture_authority": "renderer", + "required_verifier_authority": "renderer", + "requires_parser_type": false, + "dependency_obligation_ids": [], + "residual_unknown_ids": [], + "unavailable_authority": [], + "required_next_evidence": [ + "Observe main/renderer ordering and application shutdown." + ], + "disposition": "active", + "review_evidence_ids": [ + "ev_cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ] +} +``` + +### Persistence migration + +```json +{ + "obligation_id": "persistence.profile.migrate-v2", + "obligation_version": 1, + "title": "Migrate a version-one profile without losing unknown fields", + "application_layer": "persistence", + "family": "migration", + "target": { + "artifact_sha256": null, + "application_node_id": null, + "semantic_node_id": null, + "location": "profile schema v1 to v2" + }, + "required": true, + "required_case_kinds": ["positive", "negative", "malformed"], + "required_original_authority": "runtime", + "required_fixture_authority": "integration", + "required_verifier_authority": "integration", + "requires_parser_type": true, + "dependency_obligation_ids": [], + "residual_unknown_ids": [], + "unavailable_authority": [], + "required_next_evidence": [ + "Compare pre-migration and committed storage snapshots." + ], + "disposition": "active", + "review_evidence_ids": [ + "ev_dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + ] +} +``` + +### Native ABI + +```json +{ + "obligation_id": "native-abi.helper.status", + "obligation_version": 1, + "title": "Preserve the helper status-code and output-buffer contract", + "application_layer": "native-abi", + "family": "ffi", + "target": { + "artifact_sha256": null, + "application_node_id": null, + "semantic_node_id": null, + "location": "helper_status(const uint8_t *, size_t, uint8_t *, size_t *)" + }, + "required": true, + "required_case_kinds": ["positive", "negative", "malformed"], + "required_original_authority": "process", + "required_fixture_authority": "native-abi", + "required_verifier_authority": "native-abi", + "requires_parser_type": true, + "dependency_obligation_ids": [], + "residual_unknown_ids": [], + "unavailable_authority": [], + "required_next_evidence": [ + "Run the ABI harness against the shipped and reconstructed libraries." + ], + "disposition": "active", + "review_evidence_ids": [ + "ev_eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + ] +} +``` + +## Manifest and closure + +A manifest binding names one owner and its exact implementation digest. Both +`original_cases` and the reconstruction fixture list must cover every +`required_case_kinds` value with authenticated, comparable Evidence. +`parser_type` is mandatory when `requires_parser_type` is true. + +```json +{ + "obligation_id": "protocol.rpc.cancel", + "owner": { + "module_path": "src/protocol/requestRouter.ts", + "symbol": "cancelRequest", + "owner_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + "parser_type": { + "parser": "parseCancelRequest", + "schema": "cancelRequestSchema", + "domain_type": "CancelRequest" + }, + "original_cases": [ + { + "kind": "positive", + "evidence_id": "ev_6666666666666666666666666666666666666666666666666666666666666666", + "location": "/captures/positive" + }, + { + "kind": "negative", + "evidence_id": "ev_7777777777777777777777777777777777777777777777777777777777777777", + "location": "/captures/negative" + }, + { + "kind": "malformed", + "evidence_id": "ev_8888888888888888888888888888888888888888888888888888888888888888", + "location": "/captures/malformed" + }, + { + "kind": "cancellation", + "evidence_id": "ev_9999999999999999999999999999999999999999999999999999999999999999", + "location": "/captures/cancellation" + } + ], + "fixtures": [ + { + "fixture_id": "rpc.cancel.positive", + "case_kind": "positive", + "authority": "protocol", + "evidence_ids": [ + "ev_1111111111111111111111111111111111111111111111111111111111111111" + ] + }, + { + "fixture_id": "rpc.cancel.negative", + "case_kind": "negative", + "authority": "protocol", + "evidence_ids": [ + "ev_2222222222222222222222222222222222222222222222222222222222222222" + ] + }, + { + "fixture_id": "rpc.cancel.malformed", + "case_kind": "malformed", + "authority": "protocol", + "evidence_ids": [ + "ev_3333333333333333333333333333333333333333333333333333333333333333" + ] + }, + { + "fixture_id": "rpc.cancel.cancellation", + "case_kind": "cancellation", + "authority": "protocol", + "evidence_ids": [ + "ev_4444444444444444444444444444444444444444444444444444444444444444" + ] + } + ], + "verifier": { + "verifier_id": "rpc.cancel.conformance", + "claim_id": "protocol.rpc.cancel.v1", + "command": "npm run test:protocol -- cancel", + "authority": "protocol", + "status": "pass", + "result_evidence_id": "ev_5555555555555555555555555555555555555555555555555555555555555555", + "enumerated_obligation_ids": ["protocol.rpc.cancel"], + "nondeterminism": { + "mode": "partial-order", + "specification": "cancel precedes the single terminal response" + } + } +} +``` + +All referenced Evidence IDs must exist in the bundle. A green unit test cannot +close a `packaged-process` obligation. A static-only source cannot close a +runtime or process claim. Marking a claim `blocked` or `out-of-scope` records the +disposition but does not silently count it as verified. + +Pagination changes only `page` and the returned `obligations` slice. Every page +retains the same `ledger_id`, `closure_digest`, full-ledger summaries, reports, +ownership graph, dependency graph, Evidence links, and limitations. Consumers +should follow `next_offset` until it is `null`, and should reject pages whose +identity or closure digest changes mid-read. diff --git a/docs/reconstruction-readiness.md b/docs/reconstruction-readiness.md new file mode 100644 index 000000000..40d649116 --- /dev/null +++ b/docs/reconstruction-readiness.md @@ -0,0 +1,99 @@ +# Reconstruction readiness conformance + +Run the complete source-owned native, JavaScript CLI, Electron, Evidence, +comparison, obligation, replay, CLI, and MCP journey from a clean checkout: + +```sh +npm ci +npm run verify:readiness +``` + +The command builds the redistributable native conformance fixtures, inventories +the native executable through the public CLI, analyzes the generated JavaScript +CLI and Electron fixture through both CLI and MCP, verifies exact advertised +limits, evaluates the same nine-stage report through both surfaces, reads the +full MCP report resource, and proves stale/tampered replay rejection. It prints +one compact JSON summary. + +Set `REA_READINESS_REPORT_PATH` to retain the complete machine-readable report: + +```sh +REA_READINESS_REPORT_PATH=readiness-report.json npm run verify:readiness +``` + +CI runs this command on Linux, publishes the compact summary, and retains the +full report as the `reconstruction-readiness` artifact. + +## Public contracts + +- CLI: `rea evaluate-reconstruction-readiness --json` +- MCP tool: `evaluate_reconstruction_readiness` +- Full MCP resource: + `rea://evidence/{evidenceId}/reconstruction-readiness-report` + +The MCP tool result contains only report identity, status, summary, metrics, and +the resource URI. The full Evidence bundle, stage records, comparisons, +contradictions, obligation ledger, and replay inputs stay in the retained +resource instead of consuming the tool-response context. + +## Required stages + +| Stage | Required proof | +| ------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| `discover-classify` | target-free startup, artifact inventory, compatible workflow selection, and exact visible limits | +| `diagnose-environment` | broken runtime separated from analysis failure, version skew detected, and revision changed after recovery | +| `acquire-authority` | exact missing scope, narrow grant, expansion re-prompt, and no launch after denial/cancellation | +| `static-analysis` | native and JavaScript routing plus preserved ambiguity, truncation, unsupported syntax, and residual unknowns | +| `reactive-scenarios` | predicate-driven interaction, descendant settlement, Electron correlation, and cancellation cleanup Evidence | +| `compare-authority-candidate` | explicit partial-order/finite-trace semantics, primary divergence localization, and unknown for incomplete comparison | +| `preserve-contradictions` | record-and-continue, affected equivalence blocked, and unaffected comparison retained | +| `verify-reconstruction-closure` | incomplete reconstruction detected, incremental verifier closure, and authority-delegating candidates rejected | +| `export-replay` | CLI/MCP parity, process/structured failure parity, deterministic replay, tamper rejection, and stale-input rejection | + +Every stage and required check must occur exactly once and link to Evidence in +the embedded Evidence v2 bundle. `fail`, `unknown`, `unsupported`, `truncated`, +and `skipped` never aggregate to `pass`. + +## Fail-closed rules + +The evaluator rejects or withholds readiness when: + +- a bounded capability omits its effective numeric limits; +- a fixture lacks exactly one compatible selected workflow; +- an incompatible or unavailable provider was invoked; +- a typed CLI failure exits zero, or an MCP failure reports success; +- concurrent behavior uses total-order comparison; +- truncation, instability, unavailable authority, or contradiction is labeled + equivalent; +- a deliberate primary divergence is not localized; +- contradiction Evidence or its affected comparison is missing; +- the reconstruction obligation ledger is not `ready`, has no required + obligation, or retains an unowned/unverified required obligation; +- denied authority launches a process, cancellation loses diagnostics, or owned + resources remain; +- replay is nondeterministic, tampering/staleness is not detected, or the + expected source digest differs. + +Each finding names its stage, stable reason code, Evidence IDs, and detail. +Stage `capability_issue` and `next_action` fields map unsupported or failing +work to the responsible capability and remediation without claiming that the +requested analysis ran. + +## Fixtures + +The verifier uses only source-owned, redistributable fixtures: + +- `tests/conformance` native executables with stable symbols and deliberate + version divergence; +- `tests/conformance/readiness/javascript-cli`, which exposes prompts, + alternate-screen behavior, configuration precedence, child process, + persistence, loopback HTTP, and signal teardown to static reconstruction; +- `tests/conformance/readiness/electron`, which exposes main, preload, + renderer, utility, IPC, renderer restart, blocked shell effect, and deep-link + surfaces; +- the shared incomplete reconstruction and broken-runtime observations in + `RECONSTRUCTION_READINESS_EXAMPLE`. + +The synthetic example is a public typed input, not a global equivalence claim. +It demonstrates that every deliberate fault remains represented in the final +report and that only comparable Evidence can close the associated obligation. diff --git a/docs/verification/managed-conformance-manifest.json b/docs/verification/managed-conformance-manifest.json index 83468ca49..ba90b1e25 100644 --- a/docs/verification/managed-conformance-manifest.json +++ b/docs/verification/managed-conformance-manifest.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "manifest_id": "ecm_0b704bd3b1a8e8f6840d113cf064de935393db1936ca5eba1018a49ccd566cf2", + "manifest_id": "ecm_ee8e5f2f4ea090052051d2994cbff148d9c796e1bc28b2fdc145aa5fa48cd248", "verifier": { "id": "rea-managed-conformance", "version": "1" @@ -14,7 +14,7 @@ "skill_digests": [ { "skill_id": "reverse-engineer-anything", - "sha256": "fdfbbc813a935b93dbd814c509997024da4a98500b7d12887b0ee6ecf69a9fcb" + "sha256": "6a910f067c946d545f5eb711eacbdf804839dbfb6b3b7b6ea91769be37ae47ce" } ], "claims": [ diff --git a/package-lock.json b/package-lock.json index fe3472287..b304a4abc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "incur": "0.4.13", "isomorphic-git": "1.38.7", "pino": "9.14.0", + "playwright-core": "1.58.2", "plist": "5.0.0", "semver": "7.8.5", "smol-toml": "^1.7.0", @@ -4311,6 +4312,18 @@ "node": ">=16.20.0" } }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/plist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/plist/-/plist-5.0.0.tgz", diff --git a/package.json b/package.json index f00bb9921..2dab8fe33 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,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:inspector": "npm run build:cached && node scripts/verify-real-javascript-runtime-observation.mjs", "verify:javascript-runtime": "npm run build:cached && node scripts/verify-local-javascript-runtime.mjs", "verify:application-workflows": "npm run build:cached && node scripts/verify-local-application-workflows.mjs", "verify:managed": "npm run build:cached && node scripts/verify-managed-conformance.mjs", @@ -112,6 +113,7 @@ "build:fixtures": "node scripts/build-conformance-fixtures.mjs", "build:ghidra-windows-fixture": "node scripts/create-ghidra-windows-fixture.mjs", "verify:fixtures": "npm run build:fixtures && node scripts/verify-conformance-fixtures.mjs", + "verify:readiness": "npm run build:cached && npm run verify:fixtures && node scripts/verify-reconstruction-readiness.mjs", "verify:package": "npm run build:cached && node scripts/verify-package.mjs", "verify:package:windows": "npm run build:cached && node scripts/verify-windows-package.mjs", "probe:windows-capabilities": "npm run build:cached && node scripts/windows-capabilities.mjs", @@ -142,8 +144,8 @@ "@electron/asar": "4.2.0", "@jridgewell/trace-mapping": "0.3.31", "@lydell/node-pty": "1.1.0", - "@modelcontextprotocol/server": "2.0.0-beta.4", "@modelcontextprotocol/client": "2.0.0-beta.4", + "@modelcontextprotocol/server": "2.0.0-beta.4", "@xterm/addon-serialize": "0.14.0", "@xterm/headless": "6.0.0", "@zip.js/zip.js": "2.8.26", @@ -155,6 +157,7 @@ "incur": "0.4.13", "isomorphic-git": "1.38.7", "pino": "9.14.0", + "playwright-core": "1.58.2", "plist": "5.0.0", "semver": "7.8.5", "smol-toml": "^1.7.0", @@ -175,8 +178,8 @@ "lint-staged": "16.4.0", "oxlint": "1.73.0", "prettier": "3.6.2", - "typedoc": "0.28.20", "turbo": "2.10.5", + "typedoc": "0.28.20", "typescript": "5.9.3", "vitest": "4.1.10" } diff --git a/scripts/lib/browser-scenario-verifier.mjs b/scripts/lib/browser-scenario-verifier.mjs new file mode 100644 index 000000000..089657242 --- /dev/null +++ b/scripts/lib/browser-scenario-verifier.mjs @@ -0,0 +1,203 @@ +import { execFile } from "node:child_process"; +import { readdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname } from "node:path"; +import { promisify } from "node:util"; + +import { browserScenarioSchema } from "../../dist/domain/browserScenario.js"; + +const execute = promisify(execFile); + +const duplicateOriginStorage = (origin) => ({ + local_storage: [ + { + origin, + entries: [ + { + name: "rea-first-seed", + value: { + source: "literal", + value: "first", + classification: "public", + }, + }, + ], + }, + { + origin, + entries: [ + { + name: "rea-second-seed", + value: { + source: "literal", + value: "second", + classification: "public", + }, + }, + ], + }, + ], +}); + +/** Build the source-owned full-capture browser scenario. */ +export function browserScenario(browser, origin) { + return browserScenarioSchema.parse({ + schema_version: 1, + browser, + start_url: { url: `${origin}/app`, query: [] }, + allowed_origins: [origin], + environment: { + viewport: { width: 1_280, height: 720, device_scale_factor: 1.25 }, + locale: "en-US", + timezone: "UTC", + color_scheme: "light", + reduced_motion: "reduce", + service_workers: "block", + }, + actions: [ + { + step_id: "ready", + action: "wait_for", + locator: { + kind: "role", + role: "button", + name: "ax-private-label-value", + exact: true, + }, + state: "visible", + }, + { + step_id: "verify", + action: "click", + locator: { + kind: "role", + role: "button", + name: "ax-private-label-value", + exact: true, + }, + }, + ], + storage: duplicateOriginStorage(origin), + request_replay: { mode: "disabled" }, + secrets: [], + redaction: { + secret_values: "replace-with-secret-reference", + query_parameter_names: ["token"], + }, + capture: { + after_each_step: [ + "screenshot", + "dom", + "accessibility", + "url", + "history", + "storage", + ], + at_end: [ + "screenshot", + "dom", + "accessibility", + "url", + "history", + "storage", + ], + events: [ + "console", + "page-errors", + "network", + "websockets", + "frames", + "workers", + "popups", + "downloads", + ], + }, + limits: { + max_duration_ms: 30_000, + action_timeout_ms: 5_000, + navigation_timeout_ms: 10_000, + max_events: 10_000, + max_frames: 100, + max_workers: 20, + max_popups: 10, + max_websockets: 100, + max_dom_nodes: 10_000, + max_accessibility_nodes: 10_000, + max_screenshots: 8, + max_screenshot_bytes: 4 * 1_024 * 1_024, + max_storage_entries: 256, + max_total_metadata_bytes: 8 * 1_024 * 1_024, + }, + approved: true, + }); +} + +/** Run scenario capture through the public one-shot CLI. */ +export async function runScenarioCli(scenario, configuration) { + const { stdout } = await execute( + process.execPath, + [ + "scripts/rea.mjs", + "capture-browser-scenario", + JSON.stringify(scenario), + "--json", + ], + { + cwd: process.cwd(), + env: { + ...process.env, + REA_BROWSER_SCENARIO_ENABLED: "true", + REA_BROWSER_SCENARIO_AUTO_GRANT: "true", + REA_BROWSER_SCENARIO_EXECUTABLE_ROOTS_JSON: JSON.stringify([ + dirname(scenario.browser.executable_path ?? process.execPath), + ]), + REA_BROWSER_SCENARIO_ALLOWED_ENV_JSON: "[]", + ...configuration, + }, + maxBuffer: 64 * 1_024 * 1_024, + }, + ); + return JSON.parse(stdout); +} + +/** Snapshot provider-owned profile names for cleanup comparison. */ +export async function scenarioProfiles() { + return new Set( + (await readdir(tmpdir())).filter((entry) => + entry.startsWith("rea-browser-scenario-"), + ), + ); +} + +/** Assert full real-browser scenario evidence and cleanup prerequisites. */ +export function assertScenarioCapture(capture) { + if ( + capture.steps.length !== 3 || + capture.steps.some(({ status }) => status !== "completed") + ) + throw new Error("Scenario launch did not complete every declared step"); + const final = capture.steps.at(-1); + if ( + final === undefined || + Object.values(final.artifacts).some(({ state }) => state !== "captured") + ) + throw new Error( + `Scenario launch did not capture every requested artifact: ${JSON.stringify(final?.artifacts ?? null)}`, + ); + const storageNames = + final.artifacts.storage.state === "captured" + ? final.artifacts.storage.value.local_storage.map(({ name }) => name) + : []; + if ( + !storageNames.includes("rea-first-seed") || + !storageNames.includes("rea-second-seed") + ) + throw new Error("Scenario launch did not retain duplicate-origin seeds"); + if ( + capture.events.items.length === 0 || + capture.completeness.equality_eligible !== true + ) + throw new Error( + "Scenario launch did not produce complete runtime evidence", + ); +} diff --git a/scripts/lib/browser-verifier-assertions.mjs b/scripts/lib/browser-verifier-assertions.mjs index 8333ee96b..78256b1d0 100644 --- a/scripts/lib/browser-verifier-assertions.mjs +++ b/scripts/lib/browser-verifier-assertions.mjs @@ -16,3 +16,167 @@ export function sourceMapSummary(sourceMaps) { })), }; } + +/** Assert passive real-browser inventory, event, privacy, and metadata claims. */ +export function assertObservation(result, origin) { + assertObservationInventory(result, origin); + assertObservationEvents(result); + assertObservationPrivacy(result); + assertObservationMetadata(result); +} + +function assertObservationInventory(result, origin) { + if (result.target.origin !== origin) + throw new Error("Real Chrome target origin was not preserved exactly"); + if (result.frames.length < 1 || result.dom.nodes.length < 1) + throw new Error( + `Real Chrome DOM/frame observation was empty: ${JSON.stringify({ frames: result.frames.length, domTotal: result.dom.total_nodes, domReturned: result.dom.nodes.length, limitations: result.limitations })}`, + ); + if (result.accessibility.nodes.length < 1) + throw new Error("Real Chrome accessibility observation was empty"); + if ( + result.accessibility.text_capture.status !== "not_approved" || + result.accessibility.nodes.some( + (node) => node.name !== null || node.description !== null, + ) + ) + throw new Error("Accessibility text was retained without approval"); + if (!result.scripts.items.some((script) => script.url.includes("/app.js"))) + throw new Error("Real Chrome script metadata was missing"); + if (!result.resources.some((resource) => resource.url.includes("/app.js"))) + throw new Error("Real Chrome resource metadata was missing"); +} + +function assertObservationEvents(result) { + if (!result.network.requests.some((request) => request.url.includes("/api"))) + throw new Error( + "Real Chrome attach-window network observation was missing", + ); + const initiatedRequest = result.network.requests.find((request) => + request.url.includes("/api"), + ); + if ( + !initiatedRequest?.initiator.url?.includes("/app.js") || + initiatedRequest.initiator.line === null || + initiatedRequest.initiator.column === null + ) + throw new Error("Real Chrome script initiator stack was not normalized"); + if (result.console.events.length < 1) + throw new Error( + "Real Chrome attach-window console observation was missing", + ); + if (result.network.websocket_events.length < 1) + throw new Error("Real Chrome WebSocket metadata was missing"); +} + +function assertObservationPrivacy(result) { + if ( + result.console.events.some( + (event) => event.text_capture.status !== "not_approved", + ) || + result.network.requests.some( + (request) => request.body_shapes.status !== "not_approved", + ) || + result.network.websocket_events.some( + (event) => event.payload_shape !== null, + ) + ) + throw new Error( + "Sensitive text or payload shapes were retained without approval", + ); +} + +function assertObservationMetadata(result) { + if (!result.storage.local_storage_keys.includes("rea-storage-key")) + throw new Error("Real Chrome local-storage key inventory was missing"); + if (!result.storage.indexed_db_names.includes("rea-browser-db")) + throw new Error("Real Chrome IndexedDB name inventory was missing"); + if (!result.storage.cache_names.includes("rea-browser-cache")) + throw new Error("Real Chrome cache name inventory was missing"); + if ( + !result.metadata.responses.some(({ csp }) => + csp.directives.some(({ name }) => name === "default-src"), + ) || + !result.metadata.agent_hints.some( + ({ declaration }) => declaration === "service-desc", + ) + ) + throw new Error("Real Chrome safe response metadata was missing"); +} + +/** Assert real-browser static bundle and source-map findings. */ +export function assertBundleAnalysis(result) { + if ( + result.capture.scripts_analyzed < 1 || + !result.observations.routes.some(({ value }) => + value.includes("/verified-route"), + ) || + !result.observations.endpoints.some(({ value }) => value.includes("/api")) + ) + throw new Error("Real Chrome static bundle findings were missing"); + if ( + result.observations.source_maps.status !== "included" || + !result.observations.source_maps.items.some( + ({ status, original_sources, original_module_edges, mappings }) => + status === "included" && + original_sources.some(({ source }) => + source.includes("/src/main.ts"), + ) && + original_module_edges.some( + ({ specifier }) => specifier === "./dependency.ts", + ) && + mappings.length > 0, + ) + ) + throw new Error( + `Real Chrome approved source-map reconstruction was missing: ${JSON.stringify(sourceMapSummary(result.observations.source_maps))}`, + ); +} + +/** Assert explicitly approved console, JSON, and WebSocket shape capture. */ +export function assertSensitiveShapes(result) { + const request = result.network.requests.find( + (item) => + item.url.includes("/api") && + item.body_shapes.request?.properties.some( + ({ path }) => path === "/token", + ) && + item.body_shapes.response?.properties.some( + ({ path }) => path === "/secret", + ), + ); + if (request === undefined) + throw new Error("Real Chrome JSON request/response shapes were missing"); + const consoleText = result.console.events.flatMap( + (event) => event.text_capture.values, + ); + if ( + !consoleText.some(({ text }) => text.includes("[REDACTED]")) || + consoleText.some(({ text }) => text.includes("console-secret-value")) + ) + throw new Error( + "Real Chrome approved console text was not safely redacted", + ); + if ( + !result.network.websocket_events.some( + (event) => + event.payload_shape?.format === "json" && + event.payload_shape.json_shape?.properties.some( + ({ path }) => path === "/token", + ), + ) + ) + throw new Error("Real Chrome WebSocket JSON shape was missing"); + const serialized = JSON.stringify({ + console: result.console, + network: result.network, + }); + for (const secret of [ + "request-body-secret-value", + "response-secret-value", + "websocket-secret-value", + "console-secret-value", + ]) + if (serialized.includes(secret)) + throw new Error(`Approved shape capture retained raw value: ${secret}`); +} diff --git a/scripts/lib/catalog-builders.mjs b/scripts/lib/catalog-builders.mjs index 0abb315fc..9aa34df81 100644 --- a/scripts/lib/catalog-builders.mjs +++ b/scripts/lib/catalog-builders.mjs @@ -34,13 +34,23 @@ export const toolFamilyCatalog = (sources) => { { id: "browser", surface: "browser-provider", - contracts: sources.browserContracts.BROWSER_TOOL_CONTRACTS, + contracts: [ + ...sources.browserContracts.BROWSER_TOOL_CONTRACTS, + ...sources.browserScenarioContracts.BROWSER_SCENARIO_TOOL_CONTRACTS, + ], }, { id: "electron", surface: "electron-provider", contracts: sources.electronContracts.ELECTRON_TOOL_CONTRACTS, }, + { + id: "javascript-runtime", + surface: "runtime-provider", + contracts: + sources.javascriptRuntimeObservationContracts + .JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS, + }, { id: "application", surface: "application-workflow", @@ -121,10 +131,23 @@ export const providerCatalog = (sources) => { identity: sources.browserProvider.CDP_BROWSER_PROVIDER_IDENTITY, contracts: sources.browserContracts.BROWSER_TOOL_CONTRACTS, }, + { + identity: + sources.browserScenarioProvider + .PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY, + contracts: + sources.browserScenarioContracts.BROWSER_SCENARIO_TOOL_CONTRACTS, + }, { identity: sources.electronProvider.CDP_ELECTRON_PROVIDER_IDENTITY, contracts: observationContracts, }, + { + identity: sources.v8InspectorProvider.V8_INSPECTOR_PROVIDER_IDENTITY, + contracts: + sources.javascriptRuntimeObservationContracts + .JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS, + }, { identity: sources.artifactProviders.JAVASCRIPT_APPLICATION_PROVIDER, contracts: applicationContracts, diff --git a/scripts/lib/catalog-core.mjs b/scripts/lib/catalog-core.mjs index c55669e6a..25fd557e2 100644 --- a/scripts/lib/catalog-core.mjs +++ b/scripts/lib/catalog-core.mjs @@ -31,7 +31,10 @@ const SOURCE_PATHS = { managedContracts: "dist/contracts/managedToolContracts.js", managedWorkflowContracts: "dist/contracts/managedWorkflowToolContracts.js", browserContracts: "dist/contracts/browserToolContracts.js", + browserScenarioContracts: "dist/contracts/browserScenarioToolContracts.js", electronContracts: "dist/contracts/electronToolContracts.js", + javascriptRuntimeObservationContracts: + "dist/contracts/javascriptRuntimeObservationToolContracts.js", applicationContracts: "dist/contracts/applicationToolContracts.js", supportedClients: "dist/application/SupportedClients.js", hopperProvider: "dist/hopper/HopperProvider.js", @@ -39,7 +42,9 @@ const SOURCE_PATHS = { nativeProvider: "dist/native/NativeMacOSProvider.js", artifactProviders: "dist/application/InvestigationProviders.js", browserProvider: "dist/browser/CdpBrowserProvider.js", + browserScenarioProvider: "dist/browser/PlaywrightBrowserScenarioProvider.js", electronProvider: "dist/browser/CdpElectronProvider.js", + v8InspectorProvider: "dist/browser/V8InspectorProvider.js", evidence: "dist/domain/evidence.js", evidenceBundle: "dist/domain/evidenceBundle.js", evidenceCompletion: "dist/domain/evidenceCompletionLedger.js", @@ -49,8 +54,12 @@ const SOURCE_PATHS = { artifactGraph: "dist/domain/artifactGraph.js", investigationWorkspace: "dist/domain/investigationWorkspace.js", browserObservation: "dist/domain/browserObservation.js", + browserScenario: "dist/domain/browserScenario.js", + browserScenarioCapture: "dist/domain/browserScenarioCapture.js", + browserScenarioDiff: "dist/domain/browserScenarioDiff.js", browserSession: "dist/domain/browserSession.js", electronObservation: "dist/domain/electronObservation.js", + javascriptRuntimeObservation: "dist/domain/javascriptRuntimeObservation.js", webBundleAnalysis: "dist/domain/webBundleAnalysis.js", webCaptureDiff: "dist/domain/webCaptureDiff.js", managedArtifact: "dist/domain/managedArtifact.js", diff --git a/scripts/lib/catalog-schemas.mjs b/scripts/lib/catalog-schemas.mjs index 6535fb934..0acf24a3e 100644 --- a/scripts/lib/catalog-schemas.mjs +++ b/scripts/lib/catalog-schemas.mjs @@ -23,7 +23,11 @@ const valuesAt = (node, path) => { const versionAt = (schema, path) => { const versions = [ - ...new Set(valuesAt(z.toJSONSchema(schema), path).map(String)), + ...new Set( + valuesAt(z.toJSONSchema(schema, { unrepresentable: "any" }), path).map( + String, + ), + ), ]; if (versions.length !== 1) throw new Error( @@ -50,6 +54,11 @@ const durableSchemaDefinitions = (sources) => [ sources.artifactGraph.artifactExtractionResultSchema, ["extraction_manifest", "schema_version"], ], + [ + "browser_scenario", + sources.browserScenario.browserScenarioSchema, + ["schema_version"], + ], ["evidence", sources.evidence.evidenceSchema, ["schema_version"]], [ "evidence_bundle", @@ -129,6 +138,19 @@ const durableSchemaDefinitions = (sources) => [ ], ]; +const browserScenarioObservationSchemaDefinitions = (sources) => [ + [ + "browser_scenario_capture", + sources.browserScenarioCapture.browserScenarioCaptureSchema, + ["schema_version"], + ], + [ + "browser_scenario_diff", + sources.browserScenarioDiff.browserScenarioDiffSchema, + ["schema_version"], + ], +]; + const observationSchemaDefinitions = (sources) => [ [ "managed_artifact_inspection", @@ -160,6 +182,7 @@ const observationSchemaDefinitions = (sources) => [ sources.browserObservation.browserTargetListSchema, ["schema_version"], ], + ...browserScenarioObservationSchemaDefinitions(sources), [ "electron_page_inspection", sources.electronObservation.electronPageInspectionSchema, @@ -176,6 +199,16 @@ const observationSchemaDefinitions = (sources) => [ .javaScriptApplicationAnalysisResultV2Schema, ["schema_version"], ], + [ + "javascript_runtime_observation", + sources.javascriptRuntimeObservation.javascriptRuntimeObservationSchema, + ["schema_version"], + ], + [ + "javascript_runtime_target_list", + sources.javascriptRuntimeObservation.javascriptRuntimeTargetListSchema, + ["schema_version"], + ], [ "web_bundle_analysis", sources.webBundleAnalysis.webBundleAnalysisSchema, diff --git a/scripts/lib/verify-package-core.mjs b/scripts/lib/verify-package-core.mjs index b9ee8e42a..260b17ee8 100644 --- a/scripts/lib/verify-package-core.mjs +++ b/scripts/lib/verify-package-core.mjs @@ -28,6 +28,28 @@ export const runWithStatus = async (command, args, env) => { /** Parse JSON from a command output string. */ export const json = (text) => JSON.parse(text); +/** Verify that the advertised MCP catalog matches the session's available tools. */ +export const verifyAvailableToolCatalog = async (client, options) => { + const listed = await client.listTools(undefined, options); + const status = await client.callTool( + { name: "binary_session", arguments: { detail: "full" } }, + options, + ); + const availability = status.structuredContent?.result?.tool_availability; + if (!Array.isArray(availability)) + throw new Error("packaged MCP omitted tool availability"); + const expected = availability + .filter(({ available }) => available === true) + .map(({ name }) => name) + .sort(); + const observed = listed.tools.map(({ name }) => name).sort(); + if (JSON.stringify(observed) !== JSON.stringify(expected)) + throw new Error( + "packaged MCP tool inventory diverged from session availability", + ); + return observed; +}; + /** Check whether a path exists on disk. */ export const pathExists = async (path) => { try { diff --git a/scripts/verify-package-mcp.mjs b/scripts/verify-package-mcp.mjs index d403e7d87..15270d640 100644 --- a/scripts/verify-package-mcp.mjs +++ b/scripts/verify-package-mcp.mjs @@ -5,18 +5,16 @@ import { promisify } from "node:util"; import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; -import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js"; import * as prompts from "./verify-package-prompts.mjs"; -import { json } from "./lib/verify-package-core.mjs"; +import { + json, + verifyAvailableToolCatalog, +} from "./lib/verify-package-core.mjs"; const execute = promisify(execFile); const verifyMcpToolsAndPrompts = async (client, mcpOptions) => { - if ( - (await client.listTools(undefined, mcpOptions)).tools.length !== - TOOL_CONTRACTS.length - ) - throw new Error("packaged MCP tool inventory diverged from contracts"); + await verifyAvailableToolCatalog(client, mcpOptions); await prompts.verifyPromptCatalog(client, mcpOptions, prompts.names); await prompts.verifyPromptCompletion(client, mcpOptions, false); }; @@ -44,12 +42,11 @@ const verifyMcpReplay = async (client, mcpOptions, investigationReplay) => { }; const verifyMcpTargetFree = async (client, mcpOptions) => { - const result = await client.callTool( - { name: "current_document", arguments: {} }, - mcpOptions, + const names = (await client.listTools(undefined, mcpOptions)).tools.map( + ({ name }) => name, ); - if (result.isError !== true) - throw new Error("packaged target-free MCP omitted no-target error"); + if (names.includes("current_document")) + throw new Error("packaged target-free MCP advertised a target-bound tool"); }; const verifyMcpUnknownProvider = async (client, mcpOptions) => { @@ -97,14 +94,12 @@ const verifyMcpOpenAndBind = async (client, mcpOptions) => { throw new Error("packaged MCP omitted its explicit Hopper binding"); }; -const verifyMcpLinuxCurrentDocument = async (client, mcpOptions, current) => { - if ( - current.isError !== true || - !prompts - .mcpText(current) - .includes("not supported for unattended Linux analysis") - ) - throw new Error("packaged Linux MCP did not reject an unpinned Hopper"); +const verifyMcpLinuxToolAvailability = async (client, mcpOptions) => { + const names = (await client.listTools(undefined, mcpOptions)).tools.map( + ({ name }) => name, + ); + if (names.includes("current_document")) + throw new Error("packaged Linux MCP advertised unavailable Hopper tools"); }; const verifyMcpNonLinuxCurrentDocument = async ( @@ -140,13 +135,13 @@ const verifyMcpBinaryLifecycle = async (client, mcpOptions) => { mcpOptions, process.platform !== "linux", ); - const current = await client.callTool( - { name: "current_document", arguments: {} }, - mcpOptions, - ); if (process.platform === "linux") { - await verifyMcpLinuxCurrentDocument(client, mcpOptions, current); + await verifyMcpLinuxToolAvailability(client, mcpOptions); } else { + const current = await client.callTool( + { name: "current_document", arguments: {} }, + mcpOptions, + ); await verifyMcpNonLinuxCurrentDocument(client, mcpOptions, current); } const closed = await client.callTool( @@ -191,7 +186,8 @@ export async function verifyPackageMcp({ ); if ( diagnosed.healthy !== true || - diagnosed.inventory?.tools?.observed !== TOOL_CONTRACTS.length + diagnosed.inventory?.tools?.observed !== + diagnosed.inventory?.tools?.expected ) throw new Error("packaged production MCP doctor failed"); const transport = new StdioClientTransport({ diff --git a/scripts/verify-real-browser.mjs b/scripts/verify-real-browser.mjs index 4d63e144f..aef0e80ae 100644 --- a/scripts/verify-real-browser.mjs +++ b/scripts/verify-real-browser.mjs @@ -7,6 +7,7 @@ import { join } from "node:path"; import { CdpBrowserProvider } from "../dist/browser/CdpBrowserProvider.js"; import { waitForBrowserDevtoolsPort } from "../dist/browser/BrowserProcessStartup.js"; +import { PlaywrightBrowserScenarioProvider } from "../dist/browser/PlaywrightBrowserScenarioProvider.js"; import { inspectWebPageInputSchema, listBrowserTargetsInputSchema, @@ -20,7 +21,17 @@ import { } from "../dist/domain/webScreenshot.js"; import { startBrowserVerifierSite } from "./fixtures/browser-verifier-site.mjs"; import { startPageCdpProxy } from "./fixtures/page-cdp-proxy.mjs"; -import { sourceMapSummary } from "./lib/browser-verifier-assertions.mjs"; +import { + assertBundleAnalysis, + assertObservation, + assertSensitiveShapes, +} from "./lib/browser-verifier-assertions.mjs"; +import { + assertScenarioCapture, + browserScenario, + runScenarioCli, + scenarioProfiles, +} from "./lib/browser-scenario-verifier.mjs"; import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs"; const SECRET_VALUES = [ @@ -30,6 +41,7 @@ const SECRET_VALUES = [ "websocket-secret-value", "ax-private-label-value", ]; +const REAL_BROWSER_STARTUP_TIMEOUT_MS = 30_000; const verifierRun = createVerifierRun(); const executable = await browserExecutable(); @@ -68,6 +80,7 @@ try { executable, activePortPath: join(profile, "DevToolsActivePort"), stderr: () => stderr, + timeoutMs: REAL_BROWSER_STARTUP_TIMEOUT_MS, }); const endpoint = `http://127.0.0.1:${String(port)}`; const provider = new CdpBrowserProvider(); @@ -80,6 +93,7 @@ try { target_id: target, observation_ms: 1_000, include_storage_keys: true, + include_storage_fingerprints: true, }), ); if (!observed.ok) throw observed.error; @@ -208,6 +222,66 @@ try { pageProxy = await startPageCdpProxy(endpoint); await verifyPageScopedTransport(provider, pageProxy, site.origin); + const attachedScenario = await runScenarioCli( + browserScenario( + { + mode: "connect", + cdp_endpoint: endpoint, + target_id: target, + ownership: "external", + cleanup: "disconnect-only", + }, + site.origin, + ), + { + REA_BROWSER_SCENARIO_CDP_ENDPOINTS_JSON: JSON.stringify([endpoint]), + REA_BROWSER_SCENARIO_ALLOWED_ORIGINS_JSON: JSON.stringify([site.origin]), + }, + ); + if ( + attachedScenario.normalized_result?.browser?.cleanup !== + "disconnected-external" || + attachedScenario.normalized_result?.browser?.process_ownership !== + "external" + ) + throw new Error( + "Scenario CLI did not report external disconnect ownership", + ); + const versionResponse = await fetch(`${endpoint}/json/version`); + if (!versionResponse.ok || browser.exitCode !== null) + throw new Error("Scenario attachment terminated its external browser"); + + const profilesBefore = await scenarioProfiles(); + const launchedScenario = + await new PlaywrightBrowserScenarioProvider().captureScenario( + browserScenario( + { + mode: "launch", + executable_path: executable, + headless: true, + user_data: "temporary-owned", + cleanup: "close-and-delete-profile", + }, + site.origin, + ), + ); + if (!launchedScenario.ok) throw launchedScenario.error; + if ( + launchedScenario.value.browser.cleanup !== "terminated-owned-process" || + launchedScenario.value.browser.process_ownership !== "provider-owned" + ) + throw new Error("Scenario launch did not report owned-process cleanup"); + const profilesAfter = await scenarioProfiles(); + if ([...profilesAfter].some((entry) => !profilesBefore.has(entry))) + throw new Error("Scenario launch retained a temporary browser profile"); + assertScenarioCapture(launchedScenario.value); + for (const secret of ["network-secret-value", "websocket-url-secret"]) + if ( + JSON.stringify([attachedScenario, launchedScenario.value]).includes( + secret, + ) + ) + throw new Error(`Browser scenario retained sensitive value: ${secret}`); process.stdout.write( `${JSON.stringify({ @@ -226,6 +300,9 @@ try { sessionEvents: session.value.timeline.length, pageScopedTransport: true, screenshotBytes: screenshot.value.artifact.bytes, + browserScenarioCli: true, + browserScenarioAttachCleanup: "disconnected-external", + browserScenarioLaunchCleanup: "terminated-owned-process", verified: true, })}\n`, ); @@ -337,167 +414,6 @@ async function pageTarget(provider, endpoint, origin) { throw new Error("Real Chrome did not expose the local test page target"); } -function assertObservation(result, origin) { - assertObservationInventory(result, origin); - assertObservationEvents(result); - assertObservationPrivacy(result); - assertObservationMetadata(result); -} - -function assertObservationInventory(result, origin) { - if (result.target.origin !== origin) - throw new Error("Real Chrome target origin was not preserved exactly"); - if (result.frames.length < 1 || result.dom.nodes.length < 1) - throw new Error( - `Real Chrome DOM/frame observation was empty: ${JSON.stringify({ frames: result.frames.length, domTotal: result.dom.total_nodes, domReturned: result.dom.nodes.length, limitations: result.limitations })}`, - ); - if (result.accessibility.nodes.length < 1) - throw new Error("Real Chrome accessibility observation was empty"); - if ( - result.accessibility.text_capture.status !== "not_approved" || - result.accessibility.nodes.some( - (node) => node.name !== null || node.description !== null, - ) - ) - throw new Error("Accessibility text was retained without approval"); - if (!result.scripts.items.some((script) => script.url.includes("/app.js"))) - throw new Error("Real Chrome script metadata was missing"); - if (!result.resources.some((resource) => resource.url.includes("/app.js"))) - throw new Error("Real Chrome resource metadata was missing"); -} - -function assertObservationEvents(result) { - if (!result.network.requests.some((request) => request.url.includes("/api"))) - throw new Error( - "Real Chrome attach-window network observation was missing", - ); - const initiatedRequest = result.network.requests.find((request) => - request.url.includes("/api"), - ); - if ( - !initiatedRequest?.initiator.url?.includes("/app.js") || - initiatedRequest.initiator.line === null || - initiatedRequest.initiator.column === null - ) - throw new Error("Real Chrome script initiator stack was not normalized"); - if (result.console.events.length < 1) - throw new Error( - "Real Chrome attach-window console observation was missing", - ); - if (result.network.websocket_events.length < 1) - throw new Error("Real Chrome WebSocket metadata was missing"); -} - -function assertObservationPrivacy(result) { - if ( - result.console.events.some( - (event) => event.text_capture.status !== "not_approved", - ) || - result.network.requests.some( - (request) => request.body_shapes.status !== "not_approved", - ) || - result.network.websocket_events.some( - (event) => event.payload_shape !== null, - ) - ) - throw new Error( - "Sensitive text or payload shapes were retained without approval", - ); -} - -function assertObservationMetadata(result) { - if (!result.storage.local_storage_keys.includes("rea-storage-key")) - throw new Error("Real Chrome local-storage key inventory was missing"); - if (!result.storage.indexed_db_names.includes("rea-browser-db")) - throw new Error("Real Chrome IndexedDB name inventory was missing"); - if (!result.storage.cache_names.includes("rea-browser-cache")) - throw new Error("Real Chrome cache name inventory was missing"); - if ( - !result.metadata.responses.some(({ csp }) => - csp.directives.some(({ name }) => name === "default-src"), - ) || - !result.metadata.agent_hints.some( - ({ declaration }) => declaration === "service-desc", - ) - ) - throw new Error("Real Chrome safe response metadata was missing"); -} - -function assertBundleAnalysis(result) { - if ( - result.capture.scripts_analyzed < 1 || - !result.observations.routes.some(({ value }) => - value.includes("/verified-route"), - ) || - !result.observations.endpoints.some(({ value }) => value.includes("/api")) - ) - throw new Error("Real Chrome static bundle findings were missing"); - if ( - result.observations.source_maps.status !== "included" || - !result.observations.source_maps.items.some( - ({ status, original_sources, original_module_edges, mappings }) => - status === "included" && - original_sources.some(({ source }) => - source.includes("/src/main.ts"), - ) && - original_module_edges.some( - ({ specifier }) => specifier === "./dependency.ts", - ) && - mappings.length > 0, - ) - ) - throw new Error( - `Real Chrome approved source-map reconstruction was missing: ${JSON.stringify(sourceMapSummary(result.observations.source_maps))}`, - ); -} - -function assertSensitiveShapes(result) { - const request = result.network.requests.find( - (item) => - item.url.includes("/api") && - item.body_shapes.request?.properties.some( - ({ path }) => path === "/token", - ) && - item.body_shapes.response?.properties.some( - ({ path }) => path === "/secret", - ), - ); - if (request === undefined) - throw new Error("Real Chrome JSON request/response shapes were missing"); - const consoleText = result.console.events.flatMap( - (event) => event.text_capture.values, - ); - if ( - !consoleText.some(({ text }) => text.includes("[REDACTED]")) || - consoleText.some(({ text }) => text.includes("console-secret-value")) - ) - throw new Error( - "Real Chrome approved console text was not safely redacted", - ); - if ( - !result.network.websocket_events.some( - (event) => - event.payload_shape?.format === "json" && - event.payload_shape.json_shape?.properties.some( - ({ path }) => path === "/token", - ), - ) - ) - throw new Error("Real Chrome WebSocket JSON shape was missing"); - const serialized = JSON.stringify({ - console: result.console, - network: result.network, - }); - for (const secret of [ - "request-body-secret-value", - "response-secret-value", - "websocket-secret-value", - "console-secret-value", - ]) - if (serialized.includes(secret)) - throw new Error(`Approved shape capture retained raw value: ${secret}`); -} - function delay(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } diff --git a/scripts/verify-real-hopper.mjs b/scripts/verify-real-hopper.mjs index 34de6507c..847ce498b 100644 --- a/scripts/verify-real-hopper.mjs +++ b/scripts/verify-real-hopper.mjs @@ -150,12 +150,53 @@ const verifyRelationships = async (client, options, procedure) => { return related; }; +const verifyByteAccess = async (client, options, firstAddress) => { + const byteRead = requireSuccessfulTool( + await client.callTool( + { name: "read_bytes", arguments: { address: firstAddress, length: 16 } }, + options, + ), + "read_bytes", + ); + if ( + byteRead?.address !== firstAddress || + byteRead.requested_bytes !== 16 || + byteRead.returned_bytes !== 16 || + byteRead.complete !== true || + !/^[a-f0-9]{32}$/u.test(byteRead.bytes_hex) + ) { + throw new Error("read_bytes returned an invalid bounded byte window"); + } + const mappedOffset = requireSuccessfulTool( + await client.callTool( + { + name: "address_to_file_offset", + arguments: { address: firstAddress }, + }, + options, + ), + "address_to_file_offset", + ); + if ( + mappedOffset?.address !== firstAddress || + !Number.isSafeInteger(mappedOffset.file_offset) || + mappedOffset.file_offset < 0 + ) { + throw new Error("address_to_file_offset returned an invalid mapping"); + } + return { + byteReadCount: byteRead.returned_bytes, + mappedFileOffset: mappedOffset.file_offset, + }; +}; + const verifyCurrentTarget = async (client, options) => { const procedures = requireSuccessfulTool( await client.callTool({ name: "list_procedures", arguments: {} }, options), "list_procedures", ); const firstAddress = firstProcedureAddress(procedures); + const byteAccess = await verifyByteAccess(client, options, firstAddress); const basics = await verifyHopperFunctionBasics( client, options, @@ -217,6 +258,7 @@ const verifyCurrentTarget = async (client, options) => { xrefCount: relationships.xrefs.length, outgoingReferenceCount: basics.outgoingReferenceCount, instructionWindowCount: basics.instructionWindowCount, + ...byteAccess, }; }; diff --git a/scripts/verify-real-javascript-runtime-observation.mjs b/scripts/verify-real-javascript-runtime-observation.mjs new file mode 100644 index 000000000..4d01f02cc --- /dev/null +++ b/scripts/verify-real-javascript-runtime-observation.mjs @@ -0,0 +1,127 @@ +import { execFile, spawn } from "node:child_process"; +import { once } from "node:events"; +import { realpath } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execute = promisify(execFile); +const root = resolve( + dirname(fileURLToPath(import.meta.url)), + "../tests/conformance/runtime-inspector", +); +const targetPath = resolve(root, "node-target.mjs"); +const canonicalRoot = await realpath(root); +const child = spawn(process.execPath, ["--inspect=127.0.0.1:0", targetPath], { + stdio: ["ignore", "ignore", "pipe"], +}); + +try { + const webSocketUrl = await inspectorUrl(child); + const socket = new URL(webSocketUrl); + const endpoint = `http://127.0.0.1:${socket.port}`; + const environment = { + ...process.env, + REA_V8_INSPECTOR_OBSERVE_ENABLED: "true", + REA_V8_INSPECTOR_ENDPOINTS_JSON: JSON.stringify([endpoint]), + REA_V8_INSPECTOR_FILE_ROOTS_JSON: JSON.stringify([canonicalRoot]), + REA_V8_INSPECTOR_ALLOWED_ORIGINS_JSON: "[]", + }; + const listed = await runCli( + ["list-javascript-runtime-targets", endpoint, "--approved", "--json"], + environment, + ); + const target = listed.normalized_result?.targets?.items?.[0]; + if ( + listed.operation !== "list_javascript_runtime_targets" || + typeof target?.target_id !== "string" || + target.location?.kind !== "file" || + target.location.file_path !== targetPath + ) + throw new Error("Real Node Inspector target discovery did not match"); + const observed = await runCli( + [ + "observe-javascript-runtime", + endpoint, + target.target_id, + "--runtime-kind", + "node", + "--approved", + "--observation-ms", + "100", + "--json", + ], + environment, + ); + const result = observed.normalized_result; + if ( + observed.operation !== "observe_javascript_runtime" || + observed.provider?.id !== "rea-v8-inspector" || + result?.target?.target_id !== target.target_id || + result?.target?.runtime_kind !== "node" || + !Array.isArray(result?.scripts?.items) || + result.scripts.items.length === 0 + ) + throw new Error("Real Node Inspector observation did not match"); + if ( + !Array.isArray(result.directly_observed) || + !Array.isArray(result.unavailable_without_instrumentation) || + result.capture?.truncated !== false + ) + throw new Error("Real Node Inspector capture was incomplete"); + process.stdout.write( + `${JSON.stringify({ + schema_version: 1, + status: "pass", + provider: observed.provider.id, + target_id: target.target_id, + listed_evidence_id: listed.evidence_id, + observation_evidence_id: observed.evidence_id, + scripts: result.scripts.items.length, + contexts: result.execution_contexts.length, + endpoint, + })}\n`, + ); +} finally { + await terminate(child); +} + +async function inspectorUrl(process_) { + let captured = ""; + for await (const chunk of process_.stderr) { + captured += chunk.toString("utf8"); + const match = /Debugger listening on (ws:\/\/[^\s]+)/u.exec(captured); + if (match?.[1] !== undefined) return match[1]; + if (captured.length > 64 * 1_024) + throw new Error("Inspector startup output exceeded its bound"); + } + throw new Error("Node exited before reporting its Inspector URL"); +} + +async function runCli(arguments_, environment) { + const { stdout } = await execute( + process.execPath, + ["scripts/rea.mjs", ...arguments_], + { + cwd: resolve(dirname(fileURLToPath(import.meta.url)), ".."), + env: environment, + maxBuffer: 16 * 1_024 * 1_024, + }, + ); + return JSON.parse(stdout); +} + +async function terminate(process_) { + if (process_.exitCode !== null) return; + const exited = once(process_, "exit"); + process_.kill("SIGTERM"); + const terminated = await Promise.race([ + exited.then(() => true), + new Promise((resolveTimeout) => + setTimeout(() => resolveTimeout(false), 2_000), + ), + ]); + if (terminated || process_.exitCode !== null) return; + process_.kill("SIGKILL"); + await exited; +} diff --git a/scripts/verify-reconstruction-readiness.mjs b/scripts/verify-reconstruction-readiness.mjs new file mode 100644 index 000000000..ed8caa4fa --- /dev/null +++ b/scripts/verify-reconstruction-readiness.mjs @@ -0,0 +1,261 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { access, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; + +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; + +import { CATALOG_IDENTITY } from "../dist/catalogIdentity.js"; +import { RECONSTRUCTION_READINESS_EXAMPLE } from "../dist/contracts/reconstructionReadinessExample.js"; +import { parseEvidence } from "../dist/domain/evidence.js"; +import { createEvidenceBundle } from "../dist/domain/evidenceBundle.js"; +import { reconstructionReadinessReportSchema } from "../dist/domain/reconstructionReadinessSchemas.js"; +import { PACKAGE_METADATA } from "../dist/generatedPackageMetadata.js"; + +const execute = promisify(execFile); +const root = process.cwd(); +const nativePath = resolve(root, "build/conformance/c"); +const fixtureRoot = resolve(root, "tests/conformance/readiness"); +const javascriptPath = join(fixtureRoot, "javascript-cli"); +const electronPath = join(fixtureRoot, "electron"); +const environment = { + ...process.env, + REA_INVESTIGATION_INPUT_ROOTS_JSON: JSON.stringify([fixtureRoot]), +}; +const temporaryRoot = await mkdtemp(join(tmpdir(), "rea-readiness-")); + +await access(nativePath); +const nativeEvidence = await runCli(["inspect-artifact", nativePath, "--json"]); +const javascriptCliEvidence = await runCli([ + "analyze-javascript-application", + javascriptPath, + "--approved", + "--json", +]); +const electronCliEvidence = await runCli([ + "analyze-javascript-application", + electronPath, + "--approved", + "--json", +]); + +const transport = new StdioClientTransport({ + command: process.execPath, + args: ["scripts/rea.mjs", "mcp"], + cwd: root, + env: environment, + stderr: "pipe", +}); +const client = new Client({ + name: "rea-readiness-verifier", + version: "1", + capabilities: { elicitation: {} }, +}); + +try { + await client.connect(transport); + const catalog = await client.listTools(); + const readinessTool = requireTool( + catalog.tools, + "evaluate_reconstruction_readiness", + ); + const analysisTool = requireTool( + catalog.tools, + "analyze_javascript_application", + ); + assertExactAstLimit(analysisTool); + assert.equal( + readinessTool.annotations?.readOnlyHint, + false, + "readiness Evidence retention side effect must stay visible", + ); + + const javascriptMcp = await analyzeMcp(client, javascriptPath); + const electronMcp = await analyzeMcp(client, electronPath); + assert.equal( + javascriptMcp.evidence_id, + javascriptCliEvidence.evidence_id, + "CLI/MCP JavaScript analysis Evidence identity drifted", + ); + assert.equal( + electronMcp.evidence_id, + electronCliEvidence.evidence_id, + "CLI/MCP Electron analysis Evidence identity drifted", + ); + + const input = structuredClone(RECONSTRUCTION_READINESS_EXAMPLE); + input.identity.cli_version = PACKAGE_METADATA.version; + input.identity.server_version = PACKAGE_METADATA.version; + input.identity.catalog_digest = CATALOG_IDENTITY.digests.combined_sha256; + const records = [ + ...input.evidence_bundle.records, + nativeEvidence, + javascriptCliEvidence, + electronCliEvidence, + ].map(parseEvidence); + input.evidence_bundle = createEvidenceBundle(records); + setFixtureDigest(input, "native", nativeEvidence); + setFixtureDigest(input, "javascript-cli", javascriptCliEvidence); + setFixtureDigest(input, "electron", electronCliEvidence); + linkCheck(input, "discover-classify", "artifact-inventory", nativeEvidence); + linkCheck(input, "static-analysis", "native-routed", nativeEvidence); + linkCheck( + input, + "static-analysis", + "javascript-routed", + javascriptCliEvidence, + ); + linkCheck( + input, + "reactive-scenarios", + "electron-correlated", + electronCliEvidence, + ); + + const inputPath = join(temporaryRoot, "input.json"); + await writeFile(inputPath, JSON.stringify(input)); + const cliEvidence = await runCli([ + "evaluate-reconstruction-readiness", + inputPath, + "--json", + ]); + const cliReport = reconstructionReadinessReportSchema.parse( + cliEvidence.normalized_result, + ); + assert.equal(cliReport.status, "pass"); + if (process.env.REA_READINESS_REPORT_PATH !== undefined) + await writeFile( + resolve(root, process.env.REA_READINESS_REPORT_PATH), + `${JSON.stringify(cliReport, null, 2)}\n`, + ); + + const mcpResult = await client.callTool({ + name: "evaluate_reconstruction_readiness", + arguments: input, + }); + assert.notEqual(mcpResult.isError, true); + const projection = requireObject(mcpResult.structuredContent); + const projectedReport = requireObject(projection.result); + assert.equal(projectedReport.report_digest, cliReport.report_digest); + assert.equal(projectedReport.status, "pass"); + const reportUri = projectedReport.report_resource_uri; + assert.equal(typeof reportUri, "string"); + const resource = await client.readResource({ uri: reportUri }); + const resourceContent = resource.contents[0]; + if (resourceContent === undefined || !("text" in resourceContent)) + throw new TypeError("Readiness report resource is missing JSON text"); + const retained = JSON.parse(resourceContent.text); + assert.equal(retained.report.report_digest, cliReport.report_digest); + + const tampered = structuredClone(input); + tampered.replay.expected_source_digest = cliReport.source_digest; + tampered.identity.server_version = "tampered"; + const tamperedPath = join(temporaryRoot, "tampered.json"); + await writeFile(tamperedPath, JSON.stringify(tampered)); + const tamperedEvidence = await runCli([ + "evaluate-reconstruction-readiness", + tamperedPath, + "--json", + ]); + const tamperedReport = reconstructionReadinessReportSchema.parse( + tamperedEvidence.normalized_result, + ); + assert.equal(tamperedReport.status, "fail"); + assert.ok( + tamperedReport.findings.some( + ({ code }) => code === "replay-source-digest-mismatch", + ), + ); + + process.stdout.write( + `${JSON.stringify({ + schema_version: 1, + status: cliReport.status, + report_id: cliReport.report_id, + report_digest: cliReport.report_digest, + stages: cliReport.summary.stages, + findings: cliReport.summary.findings, + native_evidence_id: nativeEvidence.evidence_id, + javascript_evidence_id: javascriptCliEvidence.evidence_id, + electron_evidence_id: electronCliEvidence.evidence_id, + provider: "local-stdio", + })}\n`, + ); +} finally { + await client.close(); + await rm(temporaryRoot, { recursive: true, force: true }); +} + +async function runCli(arguments_) { + const { stdout } = await execute( + process.execPath, + ["scripts/rea.mjs", ...arguments_], + { + cwd: root, + env: environment, + maxBuffer: 64 * 1_024 * 1_024, + }, + ); + return parseEvidence(JSON.parse(stdout)); +} + +async function analyzeMcp(client_, inputPath) { + const result = await client_.callTool({ + name: "analyze_javascript_application", + arguments: { input_path: inputPath, approved: true }, + }); + assert.notEqual(result.isError, true); + const projected = requireObject(result.structuredContent); + assert.equal(typeof projected.evidence_id, "string"); + const evidence = await client_.readResource({ + uri: `rea://evidence/${projected.evidence_id}`, + }); + const content = evidence.contents[0]; + if (content === undefined || !("text" in content)) + throw new TypeError("Analysis Evidence resource is missing JSON text"); + return parseEvidence(JSON.parse(content.text)); +} + +function requireTool(tools, name) { + const tool = tools.find(({ name: toolName }) => toolName === name); + if (tool === undefined) throw new Error(`Missing public MCP tool: ${name}`); + return tool; +} + +function assertExactAstLimit(tool) { + const properties = requireObject(tool.inputSchema.properties); + const limits = requireObject(properties.limits); + const limitProperties = requireObject(limits.properties); + const astLimit = requireObject(limitProperties.max_ast_nodes); + assert.equal(astLimit.default, 2_000_000, "effective AST limit is hidden"); + assert.equal(astLimit.maximum, 20_000_000, "schema AST limit is hidden"); +} + +function requireObject(value) { + if (typeof value !== "object" || value === null || Array.isArray(value)) + throw new TypeError("Expected an object"); + return value; +} + +function setFixtureDigest(input, kind, evidence) { + const fixture = input.fixtures.find( + ({ kind: fixtureKind }) => fixtureKind === kind, + ); + if (fixture === undefined || evidence.subject === null) + throw new TypeError(`Missing ${kind} fixture identity`); + fixture.artifact_sha256 = evidence.subject.digest.sha256; +} + +function linkCheck(input, stageId, checkId, evidence) { + const stage = input.stages.find(({ stage_id: id }) => id === stageId); + const check = stage?.checks.find(({ check_id: id }) => id === checkId); + if (stage === undefined || check === undefined) + throw new TypeError(`Missing readiness check ${stageId}/${checkId}`); + stage.evidence_ids = [ + ...new Set([...stage.evidence_ids, evidence.evidence_id]), + ].sort(); + check.evidence_ids = [evidence.evidence_id]; +} diff --git a/scripts/verify-windows-package.mjs b/scripts/verify-windows-package.mjs index fb4d5213a..03e1baa7c 100644 --- a/scripts/verify-windows-package.mjs +++ b/scripts/verify-windows-package.mjs @@ -9,7 +9,7 @@ import { promisify } from "node:util"; import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; -import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js"; +import { verifyAvailableToolCatalog } from "./lib/verify-package-core.mjs"; import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs"; const exec = promisify(execFile); @@ -81,19 +81,10 @@ try { stderr: "pipe", }); const client = new Client({ name: "rea-windows-package", version: "1.0.0" }); + let toolCount = 0; try { await client.connect(server); - const catalog = await client.listTools(); - const expected = TOOL_CONTRACTS.map(({ name }) => name).sort( - (left, right) => left.localeCompare(right), - ); - const actual = catalog.tools - .map(({ name }) => name) - .sort((left, right) => left.localeCompare(right)); - if (JSON.stringify(actual) !== JSON.stringify(expected)) - throw new Error( - "Packaged Windows MCP catalog drifted from TOOL_CONTRACTS", - ); + toolCount = (await verifyAvailableToolCatalog(client)).length; } finally { await client.close(); } @@ -104,7 +95,7 @@ try { ok: true, platform: process.platform, package: packageResult.filename, - tools: TOOL_CONTRACTS.length, + tools: toolCount, ghidra_bridge: "present", })}\n`, ); diff --git a/skills/reverse-engineer-anything/SKILL.md b/skills/reverse-engineer-anything/SKILL.md index ffb62f967..6052bb3af 100644 --- a/skills/reverse-engineer-anything/SKILL.md +++ b/skills/reverse-engineer-anything/SKILL.md @@ -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: 99 - catalog_digest: "e787c57af8337f17b9da72745fcb328634af3b39be1d9ca8bfe1836274351f92" + tool_count: 110 + catalog_digest: "e8cac51f2f17a5b9a088c3ff773473c4897823401481fc0875a28fe7daf8eee0" --- # REA diff --git a/src/application/ApplicationWorkflowEvidenceResolver.ts b/src/application/ApplicationWorkflowEvidenceResolver.ts index f0231fbd6..7cd1bd2ee 100644 --- a/src/application/ApplicationWorkflowEvidenceResolver.ts +++ b/src/application/ApplicationWorkflowEvidenceResolver.ts @@ -3,10 +3,12 @@ import { z } from "zod"; import { compareApplicationVersionsRequestSchema, compareJavaScriptExportShapesRequestSchema, + compareSourceToBundleRequestSchema, traceApplicationFeatureRequestSchema, traceJavaScriptSemanticsRequestSchema, type CompareApplicationVersionsRequest, type CompareJavaScriptExportShapesRequest, + type CompareSourceToBundleRequest, type TraceApplicationFeatureRequest, type TraceJavaScriptSemanticsRequest, } from "../contracts/applicationWorkflowInputContracts.js"; @@ -16,6 +18,7 @@ import { compareApplicationVersionsInputSchema } from "../domain/javascriptAppli import { compareJavaScriptExportShapesInputSchema } from "../domain/javascriptExportShapeComparisonSchemas.js"; import { traceApplicationFeatureInputSchema } from "../domain/javascriptFeatureTraceSchemas.js"; import { traceJavaScriptSemanticsInputSchema } from "../domain/javascriptSemanticTraceSchemas.js"; +import { compareSourceToBundleInputSchema } from "../domain/sourceToBundleComparisonSchemas.js"; import { err, ok, type Result } from "../domain/result.js"; import { resolveEvidenceReferences, @@ -48,6 +51,7 @@ type ComparisonInput = z.output; type ExportShapeComparisonInput = z.output< typeof compareJavaScriptExportShapesInputSchema >; +type SourceToBundleInput = z.output; /** Parse and resolve one trace adapter request into its canonical domain input. */ export const resolveTraceApplicationFeatureRequest = ( @@ -65,16 +69,11 @@ export const resolveTraceApplicationFeatureRequestValidated = ( input: TraceApplicationFeatureRequest, lookup?: EvidenceLookup, ): Result => { - const application = - input.application === undefined - ? resolveEvidenceReferences( - lookup, - input.application_evidence_id === undefined - ? [] - : [input.application_evidence_id], - APPLICATION_GRAPH_IDENTITIES, - ) - : ok([input.application]); + const application = graphEvidence( + input.application, + input.application_evidence_id, + lookup, + ); if (!application.ok) return application; const native = resolveEvidenceReferences( lookup, @@ -82,7 +81,7 @@ export const resolveTraceApplicationFeatureRequestValidated = ( ); if (!native.ok) return native; const raw = { - application: application.value[0], + application: application.value, native_observations: [...input.native_observations, ...native.value], seed: input.seed, direction: input.direction, @@ -110,18 +109,13 @@ export const resolveTraceJavaScriptSemanticsRequestValidated = ( input: TraceJavaScriptSemanticsRequest, lookup?: EvidenceLookup, ): Result => { - const application = - input.application === undefined - ? resolveEvidenceReferences( - lookup, - input.application_evidence_id === undefined - ? [] - : [input.application_evidence_id], - APPLICATION_GRAPH_IDENTITIES, - ) - : ok([input.application]); + const application = graphEvidence( + input.application, + input.application_evidence_id, + lookup, + ); if (!application.ok) return application; - const raw = { application: application.value[0], query: input.query }; + const raw = { application: application.value, query: input.query }; const parsed = traceJavaScriptSemanticsInputSchema.safeParse(raw); return parsed.success ? ok(parsed.data) @@ -180,6 +174,42 @@ export const resolveCompareApplicationVersionsRequestValidated = ( : invalid("compare_application_versions", parsed.error, raw); }; +/** Parse and resolve one historical-source comparison adapter request. */ +export const resolveCompareSourceToBundleRequest = ( + input: unknown, + lookup?: EvidenceLookup, +): Result => { + const parsed = compareSourceToBundleRequestSchema.safeParse(input); + return parsed.success + ? resolveCompareSourceToBundleRequestValidated(parsed.data, lookup) + : invalid("compare_source_to_bundle", parsed.error, input); +}; + +/** Resolve a historical-source comparison request parsed by its public contract. */ +export const resolveCompareSourceToBundleRequestValidated = ( + input: CompareSourceToBundleRequest, + lookup?: EvidenceLookup, +): Result => { + const application = graphEvidence( + input.application, + input.application_evidence_id, + lookup, + ); + if (!application.ok) return application; + const raw = { + reference: input.reference, + application: application.value, + limits: input.limits, + ...(input.unknown_registry_approved === undefined + ? {} + : { unknown_registry_approved: input.unknown_registry_approved }), + }; + const parsed = compareSourceToBundleInputSchema.safeParse(raw); + return parsed.success + ? ok(parsed.data) + : invalid("compare_source_to_bundle", parsed.error, raw); +}; + /** Parse and resolve an export-shape adapter request into canonical input. */ export const resolveCompareJavaScriptExportShapesRequest = ( input: unknown, @@ -220,8 +250,11 @@ export const resolveCompareJavaScriptExportShapesRequestValidated = ( const graphEvidence = ( evidence: + | TraceApplicationFeatureRequest["application"] + | TraceJavaScriptSemanticsRequest["application"] | CompareApplicationVersionsRequest["left"] - | CompareJavaScriptExportShapesRequest["left"], + | CompareJavaScriptExportShapesRequest["left"] + | CompareSourceToBundleRequest["application"], evidenceId: string | undefined, lookup: EvidenceLookup | undefined, ) => { diff --git a/src/application/BrowserEvidence.ts b/src/application/BrowserEvidence.ts index 2e7306467..159a05ac0 100644 --- a/src/application/BrowserEvidence.ts +++ b/src/application/BrowserEvidence.ts @@ -24,9 +24,10 @@ import type { WebMcpDiscovery, } from "../domain/webMcpDiscovery.js"; import type { - CompareWebCapturesInput, - WebCaptureDiff, -} from "../domain/webCaptureDiff.js"; + BrowserCaptureComparison, + BrowserCaptureComparisonInput, +} from "../domain/browserCaptureComparison.js"; +import { commitBrowserScenarioNormalization } from "../domain/browserScenarioNormalization.js"; import type { CaptureWebScreenshotInput, CompareWebScreenshotsInput, @@ -40,7 +41,7 @@ type BrowserEvidenceInput = | AnalyzeWebBundleInput | ObserveWebSessionInput | DiscoverWebMcpToolsInput - | CompareWebCapturesInput + | BrowserCaptureComparisonInput | CaptureWebScreenshotInput | CompareWebScreenshotsInput; type BrowserEvidenceResult = @@ -49,7 +50,7 @@ type BrowserEvidenceResult = | WebBundleAnalysis | WebObservationSession | WebMcpDiscovery - | WebCaptureDiff + | BrowserCaptureComparison | WebScreenshot | WebScreenshotDiff; type BrowserEvidenceOperation = @@ -92,6 +93,22 @@ const browserParameters = ( input: BrowserEvidenceInput, ): EvidenceObservation["parameters"] => { if (!("cdp_endpoint" in input)) { + if ( + "before_scenario" in input && + input.before_scenario !== undefined && + input.after_scenario !== undefined + ) + return { + comparison_kind: "browser_scenario", + before_browser: input.before_scenario.browser, + after_browser: input.after_scenario.browser, + before_start_origin: input.before_scenario.scenario.start_origin, + after_start_origin: input.after_scenario.scenario.start_origin, + max_changes: input.max_changes, + normalization_sha256: commitBrowserScenarioNormalization( + input.normalization, + ).sha256, + }; if ("max_changes" in input) return { before_target_id: input.before.inspection.target.target_id, @@ -147,6 +164,7 @@ const browserParameters = ( include_websocket_shapes: input.include_websocket_shapes, include_script_sources: input.include_script_sources, include_storage_keys: input.include_storage_keys, + include_storage_fingerprints: input.include_storage_fingerprints, limits: input.limits, ...(input.include_script_sources && "source_capture_approved" in input ? { diff --git a/src/application/BrowserObservationPort.ts b/src/application/BrowserObservationPort.ts index d966785ab..1d72e4f4d 100644 --- a/src/application/BrowserObservationPort.ts +++ b/src/application/BrowserObservationPort.ts @@ -19,9 +19,9 @@ import type { WebMcpDiscovery, } from "../domain/webMcpDiscovery.js"; import type { - CompareWebCapturesInput, - WebCaptureDiff, -} from "../domain/webCaptureDiff.js"; + BrowserCaptureComparison, + BrowserCaptureComparisonInput, +} from "../domain/browserCaptureComparison.js"; import type { CaptureWebScreenshotInput, CompareWebScreenshotsInput, @@ -55,8 +55,8 @@ export interface BrowserObservationPort { options?: ExecutionOptions, ): Promise>; compareCaptures( - input: CompareWebCapturesInput, - ): Promise>; + input: BrowserCaptureComparisonInput, + ): Promise>; captureScreenshot( input: CaptureWebScreenshotInput, options?: ExecutionOptions, diff --git a/src/application/BrowserObservationService.ts b/src/application/BrowserObservationService.ts index 79b923849..0c0cfe442 100644 --- a/src/application/BrowserObservationService.ts +++ b/src/application/BrowserObservationService.ts @@ -7,7 +7,7 @@ import { import type { AnalyzeWebBundleInput } from "../domain/webBundleAnalysis.js"; import type { ObserveWebSessionInput } from "../domain/browserSession.js"; import type { DiscoverWebMcpToolsInput } from "../domain/webMcpDiscovery.js"; -import type { CompareWebCapturesInput } from "../domain/webCaptureDiff.js"; +import type { BrowserCaptureComparisonInput } from "../domain/browserCaptureComparison.js"; import type { CaptureWebScreenshotInput, CompareWebScreenshotsInput, @@ -151,7 +151,7 @@ export const discoverWebMcpTools = async ( /** Compare two already-normalized web captures without external access. */ export const compareWebCaptureEvidence = async ( browser: BrowserObservationPort | undefined, - input: CompareWebCapturesInput, + input: BrowserCaptureComparisonInput, ): Promise> => { const ready = requireBrowser(browser, "compare_web_captures"); if (!ready.ok) return ready; diff --git a/src/application/BrowserScenarioCapturePort.ts b/src/application/BrowserScenarioCapturePort.ts new file mode 100644 index 000000000..c48df87d3 --- /dev/null +++ b/src/application/BrowserScenarioCapturePort.ts @@ -0,0 +1,14 @@ +import type { ExecutionOptions, ProviderIdentity } from "./AnalysisProvider.js"; +import type { AnalysisError } from "../domain/errors.js"; +import type { BrowserScenario } from "../domain/browserScenario.js"; +import type { BrowserScenarioCapture } from "../domain/browserScenarioCapture.js"; +import type { Result } from "../domain/result.js"; + +/** Provider-neutral application boundary for controlled browser scenarios. */ +export interface BrowserScenarioCapturePort { + identity(): ProviderIdentity; + captureScenario( + scenario: BrowserScenario, + options?: ExecutionOptions, + ): Promise>; +} diff --git a/src/application/BrowserScenarioCaptureService.ts b/src/application/BrowserScenarioCaptureService.ts new file mode 100644 index 000000000..43272c73c --- /dev/null +++ b/src/application/BrowserScenarioCaptureService.ts @@ -0,0 +1,99 @@ +import type { ExecutionOptions } from "./AnalysisProvider.js"; +import type { BrowserScenarioCapturePort } from "./BrowserScenarioCapturePort.js"; +import { createBrowserScenarioEvidence } from "./BrowserScenarioEvidence.js"; +import type { PermissionAuthority } from "./PermissionAuthority.js"; +import { digestJson } from "./JavaScriptReplayPlanning.js"; +import { + AnalysisCapabilityUnavailableError, + AnalysisProtocolError, + PermissionRequiredError, + type AnalysisError, +} from "../domain/errors.js"; +import type { Evidence } from "../domain/evidence.js"; +import { isLiteralLoopbackHostname } from "../domain/browserObservation.js"; +import type { BrowserScenario } from "../domain/browserScenario.js"; +import { err, type Result } from "../domain/result.js"; + +const OPERATION = "capture_browser_scenario" as const; +const PROVIDER_ID = "rea-playwright-browser-scenario"; + +const networkScope = (origins: readonly string[]): "loopback" | "external" => + origins.every((origin) => isLiteralLoopbackHostname(new URL(origin).hostname)) + ? "loopback" + : "external"; + +const authorizeScenario = async ( + authority: PermissionAuthority | undefined, + scenario: BrowserScenario, +): Promise> => { + if (authority === undefined) + return err( + new AnalysisCapabilityUnavailableError( + PROVIDER_ID, + OPERATION, + "browser scenario permission policy is not configured", + ), + ); + const authorized = await authority.authorize( + { + capability: "browser_automate", + roots: [], + executables: + scenario.browser.mode === "launch" + ? [scenario.browser.executable_path] + : [], + environment_names: scenario.secrets.map( + ({ environment_variable: name }) => name, + ), + origins: [ + ...(scenario.browser.mode === "connect" + ? [scenario.browser.cdp_endpoint] + : []), + ...scenario.allowed_origins, + ], + network: networkScope(scenario.allowed_origins), + mount: false, + operation_identity: `${OPERATION}:${digestJson(scenario)}`, + }, + "write", + ); + if (!authorized.ok) + return err( + authorized.error instanceof PermissionRequiredError + ? authorized.error + : new AnalysisProtocolError(authorized.error.message, { + cause: authorized.error, + }), + ); + return { ok: true, value: true }; +}; + +/** Authorize and execute one controlled browser scenario. */ +export const captureBrowserScenario = async ( + provider: BrowserScenarioCapturePort | undefined, + authority: PermissionAuthority | undefined, + scenario: BrowserScenario, + options: ExecutionOptions = {}, +): Promise> => { + const authorized = await authorizeScenario(authority, scenario); + if (!authorized.ok) return authorized; + if (provider === undefined) + return err( + new AnalysisCapabilityUnavailableError( + PROVIDER_ID, + OPERATION, + "browser scenario provider is not configured", + ), + ); + const captured = await provider.captureScenario(scenario, options); + return captured.ok + ? { + ok: true, + value: createBrowserScenarioEvidence( + scenario, + captured.value, + provider.identity(), + ), + } + : captured; +}; diff --git a/src/application/BrowserScenarioEvidence.ts b/src/application/BrowserScenarioEvidence.ts new file mode 100644 index 000000000..3a9eb1ea4 --- /dev/null +++ b/src/application/BrowserScenarioEvidence.ts @@ -0,0 +1,59 @@ +import type { ProviderIdentity } from "./AnalysisProvider.js"; +import type { BrowserScenario } from "../domain/browserScenario.js"; +import type { BrowserScenarioCapture } from "../domain/browserScenarioCapture.js"; +import { sanitizeBrowserUrl } from "../domain/browserObservation.js"; +import { + createEvidence, + type Evidence, + type EvidenceObservation, +} from "../domain/evidence.js"; +import { jsonValueSchema } from "../domain/jsonValue.js"; +import { digestJson } from "./JavaScriptReplayPlanning.js"; + +const browserScenarioParameters = ( + scenario: BrowserScenario, +): EvidenceObservation["parameters"] => ({ + scenario_sha256: digestJson(scenario), + browser_mode: scenario.browser.mode, + start_url: sanitizeBrowserUrl(scenario.start_url.url), + allowed_origins: scenario.allowed_origins, + actions: scenario.actions.map(({ step_id, action }) => ({ + step_id, + action, + })), + secret_declarations: scenario.secrets.map( + ({ secret_id, environment_variable, purpose, redaction }) => ({ + secret_id, + environment_variable, + purpose, + redaction, + }), + ), + capture: scenario.capture, + limits: scenario.limits, +}); + +/** Create Evidence v2 without retaining resolved scenario secret values. */ +export const createBrowserScenarioEvidence = ( + scenario: BrowserScenario, + capture: BrowserScenarioCapture, + provider: ProviderIdentity, +): Evidence => + createEvidence(undefined, provider, { + predicateType: "rea.browser-scenario-capture/v1", + operation: "capture_browser_scenario", + parameters: browserScenarioParameters(scenario), + result: jsonValueSchema.parse(capture), + confidence: "observed", + authority: "controlled-replay", + environment: { + id: `${capture.browser.product}@${capture.browser.version}`, + platform: process.platform, + architecture: process.arch, + isolation: + capture.browser.process_ownership === "provider-owned" + ? "process" + : "none", + }, + limitations: capture.limitations, + }); diff --git a/src/application/CallPathTracing.ts b/src/application/CallPathTracing.ts new file mode 100644 index 000000000..58c42f060 --- /dev/null +++ b/src/application/CallPathTracing.ts @@ -0,0 +1,248 @@ +import type { AnalysisOperation } from "./AnalysisProvider.js"; +import { projectAnalysisError, type AnalysisError } from "../domain/errors.js"; +import { parseRelatedAddresses } from "../domain/hopperValues.js"; +import type { JsonValue } from "../domain/jsonValue.js"; +import { ok, type Result } from "../domain/result.js"; +import type { EnhancedResult } from "./EnhancedToolTypes.js"; + +type AnalysisCall = ( + name: AnalysisOperation, + arguments_: Readonly>, + signal?: AbortSignal, +) => Promise>; + +export interface CallPathTraceInput { + readonly start: string; + readonly goal?: string | undefined; + readonly direction: "forward" | "backward"; + readonly max_depth: number; + readonly max_nodes: number; + readonly max_operations: number; +} + +interface TraceState { + readonly visited: Map< + string, + { readonly depth: number; readonly path: string[] } + >; + readonly queue: string[]; + readonly edges: Map< + string, + { + readonly source_address: string; + readonly target_address: string; + readonly discovery_depth: number; + } + >; + readonly failures: Array<{ + readonly address: string; + readonly error: ReturnType; + }>; + readonly frontier: Set; + readonly residual: Set; + operations: number; + queueIndex: number; + traversalPath: string[]; +} + +interface AddressExpansion { + readonly input: CallPathTraceInput; + readonly state: TraceState; + readonly address: string; + readonly current: { readonly depth: number; readonly path: string[] }; + readonly signal?: AbortSignal; +} + +interface DiscoveredEdge { + readonly address: string; + readonly relatedAddress: string; + readonly discoveryDepth: number; +} + +/** Trace one deterministic bounded direct caller/callee path. */ +export const traceCallPath = async ( + call: AnalysisCall, + input: CallPathTraceInput, + signal?: AbortSignal, +): EnhancedResult => { + const state = createTraceState(input); + while ( + state.queueIndex < state.queue.length && + state.traversalPath.length === 0 + ) { + const address = state.queue[state.queueIndex++]; + if (address === undefined) break; + const current = state.visited.get(address); + if (current === undefined) continue; + if (current.depth >= input.max_depth) { + state.frontier.add(address); + state.residual.add("Call-path traversal reached the depth limit."); + continue; + } + if (state.operations >= input.max_operations) { + recordPendingFrontier(state, input); + state.residual.add("Call-path traversal reached the operation budget."); + break; + } + state.operations += 1; + const found = await expandAddress(call, { + input, + state, + address, + current, + ...(signal === undefined ? {} : { signal }), + }); + if (found) break; + } + return ok(projectTrace(input, state)); +}; + +const createTraceState = (input: CallPathTraceInput): TraceState => ({ + visited: new Map([[input.start, { depth: 0, path: [input.start] }]]), + queue: [input.start], + edges: new Map(), + failures: [], + frontier: new Set(), + residual: new Set(), + operations: 0, + queueIndex: 0, + traversalPath: input.goal === input.start ? [input.start] : [], +}); + +const expandAddress = async ( + call: AnalysisCall, + expansion: AddressExpansion, +): Promise => { + const { input, state, address, current, signal } = expansion; + const relation = input.direction === "forward" ? "callees" : "callers"; + const tool = + input.direction === "forward" ? "procedure_callees" : "procedure_callers"; + const result = await call(tool, { procedure: address }, signal); + if (!result.ok) { + state.failures.push({ + address, + error: projectAnalysisError(result.error), + }); + state.residual.add(`Call relationships were unavailable for ${address}.`); + return false; + } + const related = parseRelatedAddresses(result.value, relation); + if (!related.ok) { + state.failures.push({ + address, + error: projectAnalysisError(related.error), + }); + state.residual.add(`Call relationships were unreadable for ${address}.`); + return false; + } + for (const relatedAddress of sortedUniqueAddresses(related.value)) { + recordEdge(input, state, { + address, + relatedAddress, + discoveryDepth: current.depth + 1, + }); + const path = state.visited.get(relatedAddress)?.path ?? [ + ...current.path, + relatedAddress, + ]; + if (state.visited.has(relatedAddress)) { + if (relatedAddress === input.goal) { + state.traversalPath = path; + return true; + } + continue; + } + if (state.visited.size >= input.max_nodes) { + if (state.frontier.size < input.max_nodes) + state.frontier.add(relatedAddress); + state.residual.add("Call-path traversal reached the node limit."); + continue; + } + state.visited.set(relatedAddress, { + depth: current.depth + 1, + path, + }); + if (relatedAddress === input.goal) { + state.traversalPath = path; + return true; + } + state.queue.push(relatedAddress); + } + return false; +}; + +const recordEdge = ( + input: CallPathTraceInput, + state: TraceState, + edge: DiscoveredEdge, +): void => { + const sourceAddress = + input.direction === "forward" ? edge.address : edge.relatedAddress; + const targetAddress = + input.direction === "forward" ? edge.relatedAddress : edge.address; + const edgeKey = `${sourceAddress}\u0000${targetAddress}`; + if (state.edges.has(edgeKey)) return; + if (state.edges.size >= input.max_nodes) { + state.residual.add("Call-path traversal reached the edge limit."); + return; + } + state.edges.set(edgeKey, { + source_address: sourceAddress, + target_address: targetAddress, + discovery_depth: edge.discoveryDepth, + }); +}; + +const recordPendingFrontier = ( + state: TraceState, + input: CallPathTraceInput, +): void => { + for (const pending of state.queue.slice(state.queueIndex - 1)) + if (state.frontier.size < input.max_nodes) state.frontier.add(pending); +}; + +const projectTrace = (input: CallPathTraceInput, state: TraceState) => ({ + start: input.start, + goal: input.goal ?? null, + direction: input.direction, + goal_status: + input.goal === undefined + ? "not_requested" + : state.traversalPath.length > 0 + ? "reached" + : "not_reached", + nodes: [...state.visited.entries()] + .map(([address, { depth }]) => ({ address, depth })) + .sort( + (left, right) => + left.depth - right.depth || compareAddress(left.address, right.address), + ), + edges: [...state.edges.values()].sort( + (left, right) => + left.discovery_depth - right.discovery_depth || + compareAddress(left.source_address, right.source_address) || + compareAddress(left.target_address, right.target_address), + ), + traversal_path: state.traversalPath, + failures: state.failures, + frontier: [...state.frontier].sort(compareAddress), + limits: { + max_depth: input.max_depth, + max_nodes: input.max_nodes, + max_operations: input.max_operations, + nodes_visited: state.visited.size, + operations_used: state.operations, + }, + truncated: state.residual.size > 0, + residual_unknowns: [...state.residual], + limitations: [ + "Direct provider call relationships may omit unresolved indirect calls.", + "A not_reached goal is only absent from this bounded traversal, not from the complete program.", + ], +}); + +const compareAddress = (left: string, right: string): number => + left < right ? -1 : left > right ? 1 : 0; + +const sortedUniqueAddresses = (addresses: readonly string[]): string[] => + [...new Set(addresses)].sort(compareAddress); diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index c1f273216..467b6751c 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { TOOL_CONTRACTS } from "../contracts/toolContracts.js"; +import type { ClientFeatureAvailability } from "../contracts/toolOutputSchemaPrimitives.js"; import type { JsonValue } from "../domain/jsonValue.js"; const statusSchema = z.object({ @@ -19,6 +20,7 @@ const statusSchema = z.object({ type ToolAvailabilityReason = | "available" + | "client_capability_missing" | "target_required" | "provider_missing" | "provider_unavailable" @@ -32,15 +34,41 @@ type ProviderDescriptor = { readonly reason: string | null; readonly availability_code?: string | null; }; -type AvailabilityPolicy = { +export type AvailabilityPolicy = { readonly processCaptureEnabled: boolean; readonly evidenceFileRoots: number; readonly investigationInputRoots: number; readonly browserObservationEnabled?: boolean; + readonly browserScenarioEnabled?: boolean; readonly electronObservationEnabled?: boolean; + readonly v8InspectorObservationEnabled?: boolean; readonly javascriptReplayEnabled?: boolean; readonly managedRuntimeEnabled?: boolean; }; + +type ClientFeatureName = keyof ClientFeatureAvailability; + +const NO_CLIENT_FEATURES: ClientFeatureAvailability = { + elicitation_form: false, + elicitation_url: false, + roots: false, + sampling: false, +}; + +const CLIENT_FEATURE_REQUIREMENTS: Readonly< + Record< + string, + { + readonly required: readonly ClientFeatureName[]; + readonly optional: readonly ClientFeatureName[]; + } + > +> = { + capture_process_scenario: { + required: [], + optional: ["elicitation_form"], + }, +}; type Availability = { readonly reason: ToolAvailabilityReason; readonly remediation: string | null; @@ -82,10 +110,11 @@ const ENHANCED_REQUIREMENTS: Readonly> = { ], }; -/** Build stable per-operation availability without hiding familiar tools. */ +/** Build stable per-operation availability for discovery and tool visibility. */ export const buildCapabilityInventory = ( sessionStatus: JsonValue, policy: AvailabilityPolicy, + clientFeatures: ClientFeatureAvailability = NO_CLIENT_FEATURES, ) => { const status = statusSchema.parse(sessionStatus); const descriptors = new Map( @@ -107,12 +136,22 @@ export const buildCapabilityInventory = ( descriptors, policy, }); + const clientRequirements = clientRequirementsFor( + contract.name, + clientFeatures, + ); + const clientBlocked = clientRequirements.missing_required.length > 0; return { name: contract.name, surface: contract.kind, - available: availability.reason === "available", - reason: availability.reason, - remediation: availability.remediation, + available: availability.reason === "available" && !clientBlocked, + reason: clientBlocked + ? ("client_capability_missing" as const) + : availability.reason, + remediation: clientBlocked + ? `Use an MCP client that supports: ${clientRequirements.missing_required.join(", ")}.` + : availability.remediation, + client_requirements: clientRequirements, effects: { ...contract.effects }, annotations: { read_only: contract.annotations.readOnlyHint ?? false, @@ -124,6 +163,26 @@ export const buildCapabilityInventory = ( }).sort((left, right) => left.name.localeCompare(right.name)); }; +const clientRequirementsFor = ( + name: string, + clientFeatures: ClientFeatureAvailability, +) => { + const requirements = CLIENT_FEATURE_REQUIREMENTS[name] ?? { + required: [], + optional: [], + }; + return { + required: [...requirements.required], + optional: [...requirements.optional], + missing_required: requirements.required.filter( + (feature) => !clientFeatures[feature], + ), + missing_optional: requirements.optional.filter( + (feature) => !clientFeatures[feature], + ), + }; +}; + const availabilityFor = (context: AvailabilityContext): Availability => { const javascriptApplication = javascriptApplicationAvailability(context); if (javascriptApplication !== null) return javascriptApplication; @@ -156,6 +215,8 @@ const policyAvailability = ({ kind, policy, }: AvailabilityContext): Availability | null => { + const browser = browserPolicyAvailability(name, kind, policy); + if (browser !== null) return browser; if (name === "capture_process_scenario" && !policy.processCaptureEnabled) return { reason: "policy_disabled", @@ -187,14 +248,6 @@ const policyAvailability = ({ reason: "policy_disabled", remediation: "Configure or grant an evidence_read root.", }; - if (kind === "browser-provider") - return policy.browserObservationEnabled === true - ? { reason: "available", remediation: null } - : { - reason: "policy_disabled", - remediation: - "Enable browser observation and configure exact CDP endpoint and page origins.", - }; if (kind === "electron-provider") return policy.electronObservationEnabled === true ? { reason: "available", remediation: null } @@ -203,16 +256,47 @@ const policyAvailability = ({ remediation: "Enable Electron observation and configure a loopback CDP endpoint and canonical file roots.", }; + if (kind === "runtime-provider") + return policy.v8InspectorObservationEnabled === true + ? { reason: "available", remediation: null } + : { + reason: "policy_disabled", + remediation: + "Enable V8 Inspector observation and configure exact loopback endpoints plus canonical file roots or exact origins.", + }; if (kind === "session") return { reason: "available", remediation: null }; return null; }; +const browserPolicyAvailability = ( + name: string, + kind: ToolKind, + policy: AvailabilityPolicy, +): Availability | null => { + if (name === "capture_browser_scenario") + return policy.browserScenarioEnabled === true + ? { reason: "available", remediation: null } + : { + reason: "policy_disabled", + remediation: + "Enable browser scenarios and configure exact executable roots or loopback CDP endpoints, page origins, and environment secret names.", + }; + if (kind !== "browser-provider") return null; + return policy.browserObservationEnabled === true + ? { reason: "available", remediation: null } + : { + reason: "policy_disabled", + remediation: + "Enable browser observation and configure exact CDP endpoint and page origins.", + }; +}; + const targetAvailability = ({ kind, targetKind, targetOpen, }: AvailabilityContext): Availability | null => { - if (!targetOpen) + if (!targetOpen && (kind === "official-proxy" || kind === "enhanced")) return { reason: "target_required", remediation: "Call open_binary with a supported local target.", @@ -238,6 +322,13 @@ const providerAvailability = ({ }: AvailabilityContext): Availability => { if (kind === "enhanced") return composedAvailability(name, descriptors); const descriptor = descriptors.get(name); + if ( + descriptor === undefined && + (kind === "artifact-provider" || + kind === "managed-provider" || + kind === "native-provider") + ) + return { reason: "available", remediation: null }; if (descriptor === undefined) return { reason: "provider_missing", diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index b3d65d031..2ceac7128 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -40,6 +40,7 @@ import { workflowAnalysisProfile, } from "./InvestigationProviders.js"; import type { AnalysisProviderSelector } from "../contracts/providerSelection.js"; +import { artifactInspectionResultSchema } from "../domain/artifactInspection.js"; export { runCapabilityStatus, @@ -144,7 +145,7 @@ const authorizeAnalysis = async ( ); } if ( - tool === "inventory_artifact" && + ["inventory_artifact", "inspect_artifact"].includes(tool) && arguments_.native_mount_approved === true ) { const result = await authority.authorize( @@ -155,7 +156,7 @@ const authorizeAnalysis = async ( environment_names: [], network: "none", mount: true, - operation_identity: "inventory_artifact:native_mount", + operation_identity: `${tool}:native_mount`, }, "read", ); @@ -381,6 +382,11 @@ const executeAnalysisTool = async (input: { rawResult: result.value.rawResult, limitations: result.value.limitations, locations: result.value.locations, + evidenceLinks: + tool === "inspect_artifact" + ? artifactInspectionResultSchema.parse(result.value.result) + .evidence_links + : [], }, ); return { output: evidence, evidence }; diff --git a/src/application/EnhancedLiteralTracing.ts b/src/application/EnhancedLiteralTracing.ts new file mode 100644 index 000000000..026afc85e --- /dev/null +++ b/src/application/EnhancedLiteralTracing.ts @@ -0,0 +1,185 @@ +import type { AnalysisOperation } from "./AnalysisProvider.js"; +import { AnalysisOutputError, type AnalysisError } from "../domain/errors.js"; +import { parseAddressedPage } from "../domain/hopperValues.js"; +import type { JsonValue } from "../domain/jsonValue.js"; +import { err, ok, type Result } from "../domain/result.js"; +import type { + EnhancedResult, + TraceMatch, + TraceReference, +} from "./EnhancedToolTypes.js"; + +type AnalysisCall = ( + name: AnalysisOperation, + arguments_: Readonly>, + signal?: AbortSignal, +) => Promise>; + +type LiteralSearchSource = readonly [ + "list_strings" | "list_procedures", + "string" | "procedure", +]; + +interface ReferenceTraceRequest { + readonly matches: readonly TraceMatch[]; + readonly limit: number; + readonly operationBudget: number; + readonly signal?: AbortSignal; +} + +const FEATURE_SEARCH_SOURCES: readonly LiteralSearchSource[] = [ + ["list_strings", "string"], + ["list_procedures", "procedure"], +]; +const STRING_SEARCH_SOURCES: readonly LiteralSearchSource[] = [ + ["list_strings", "string"], +]; + +export interface LiteralTraceInput { + readonly query: string; + readonly case_sensitive: boolean; + readonly limit: number; + readonly max_operations: number; +} + +/** Trace a bounded feature or string literal through provider xrefs. */ +export const traceLiteralFeature = async ( + call: AnalysisCall, + input: LiteralTraceInput, + scope: "feature" | "string", + signal?: AbortSignal, +): EnhancedResult => { + const sources = + scope === "feature" ? FEATURE_SEARCH_SOURCES : STRING_SEARCH_SOURCES; + const searched = await literalMatches(call, input, sources, signal); + if (!searched.ok) return searched; + const residual = new Set(); + const traced = await traceReferences(call, { + matches: searched.value.matches, + limit: input.limit, + operationBudget: input.max_operations - searched.value.operations, + ...(signal === undefined ? {} : { signal }), + }); + if (!traced.ok) return traced; + for (const reason of traced.value.residual) residual.add(reason); + const operations = searched.value.operations + traced.value.operations; + if (operations >= input.max_operations) + residual.add("Investigation reached the operation budget."); + if (searched.value.matches.length >= input.limit) + residual.add("Literal matches reached the configured limit."); + return ok({ + query: input.query, + search_mode: "literal", + operations_used: operations, + operation_budget: input.max_operations, + matches: searched.value.matches, + references: traced.value.references, + truncated: residual.size > 0, + residual_unknowns: [...residual], + }); +}; + +const literalMatches = async ( + call: AnalysisCall, + input: LiteralTraceInput, + sources: readonly LiteralSearchSource[], + signal?: AbortSignal, +): Promise< + Result<{ matches: TraceMatch[]; operations: number }, AnalysisError> +> => { + let operations = 0; + const matches: TraceMatch[] = []; + const needle = input.case_sensitive ? input.query : input.query.toLowerCase(); + for (const [tool, type] of sources) { + let offset = 0; + while (operations < input.max_operations && matches.length < input.limit) { + operations += 1; + const result = await call(tool, { offset, limit: 500 }, signal); + if (!result.ok) return result; + const page = parseAddressedPage(result.value); + if (!page.ok) return page; + for (const item of page.value.items) { + const haystack = input.case_sensitive + ? item.name + : item.name.toLowerCase(); + if (haystack.includes(needle)) + matches.push({ type, address: item.address, value: item.name }); + if (matches.length >= input.limit) break; + } + if (!page.value.hasMore || page.value.nextOffset === null) break; + offset = page.value.nextOffset; + } + } + return ok({ matches, operations }); +}; + +const traceReferences = async ( + call: AnalysisCall, + request: ReferenceTraceRequest, +): Promise< + Result< + { references: TraceReference[]; operations: number; residual: string[] }, + AnalysisError + > +> => { + let operations = 0; + const references: TraceReference[] = []; + const residual = new Set(); + for (const match of request.matches) { + if (operations >= request.operationBudget) { + residual.add("Reference traversal stopped at the operation budget."); + break; + } + operations += 1; + const xrefs = await call( + "xrefs", + { address: match.address }, + request.signal, + ); + if (!xrefs.ok) return xrefs; + if (!Array.isArray(xrefs.value)) + return err( + new AnalysisOutputError( + "xrefs", + "provider returned a non-array result", + ), + ); + for (const source of xrefs.value) { + if (typeof source !== "string") + return err( + new AnalysisOutputError( + "xrefs", + "provider returned a non-address value", + ), + ); + if (operations >= request.operationBudget) { + residual.add( + "Containing-procedure resolution stopped at the operation budget.", + ); + break; + } + operations += 1; + const resolved = await call( + "resolve_containing_procedure", + { address: source }, + request.signal, + ); + if (!resolved.ok) return resolved; + references.push({ + target_address: match.address, + source_address: source, + containing_procedure: resolved.value, + }); + if (references.length >= request.limit) { + residual.add("Reference results reached the configured limit."); + break; + } + } + if (references.length >= request.limit) break; + } + return ok({ + references, + operations, + residual: [...residual], + }); +}; diff --git a/src/application/EnhancedTools.ts b/src/application/EnhancedTools.ts index 12c4539ff..96cf7ed6f 100644 --- a/src/application/EnhancedTools.ts +++ b/src/application/EnhancedTools.ts @@ -12,7 +12,6 @@ import { } from "../domain/errors.js"; import { addressDistance, - parseAddressedPage, parseDocuments, parseFunctionDossier, parseListCount, @@ -31,11 +30,11 @@ import type { JsonValue } from "../domain/jsonValue.js"; import { invalidEnhancedInput, type EnhancedResult, - type TraceMatch, - type TraceReference, type ValidatedEnhancedCall, } from "./EnhancedToolTypes.js"; import { readAllAddressed } from "./EnhancedToolPagination.js"; +import { traceCallPath } from "./CallPathTracing.js"; +import { traceLiteralFeature } from "./EnhancedLiteralTracing.js"; export type { ValidatedEnhancedCall } from "./EnhancedToolTypes.js"; /** @@ -52,6 +51,12 @@ export class EnhancedTools { input: unknown, signal?: AbortSignal, ): EnhancedResult { + if ( + name === "trace_feature" || + name === "find_code_for_string" || + name === "trace_call_path" + ) + return this.#executeTracing(name, input, signal); switch (name) { case "swift_classes": { const parsed = enhancedInputSchemas.swift_classes.safeParse(input); @@ -99,12 +104,6 @@ export class EnhancedTools { ? this.executeValidated({ name, input: parsed.data }, signal) : invalidEnhancedInput(name, parsed.error); } - case "trace_feature": { - const parsed = enhancedInputSchemas.trace_feature.safeParse(input); - return parsed.success - ? this.executeValidated({ name, input: parsed.data }, signal) - : invalidEnhancedInput(name, parsed.error); - } } } @@ -133,10 +132,55 @@ export class EnhancedTools { case "analyze_function": return this.#analyzeFunction(call.input, signal); case "trace_feature": - return this.#traceFeature(call.input, signal); + return traceLiteralFeature( + (name, arguments_, operationSignal) => + this.#call(name, arguments_, operationSignal), + call.input, + "feature", + signal, + ); + case "find_code_for_string": + return traceLiteralFeature( + (name, arguments_, operationSignal) => + this.#call(name, arguments_, operationSignal), + call.input, + "string", + signal, + ); + case "trace_call_path": + return traceCallPath( + (name, arguments_, operationSignal) => + this.#call(name, arguments_, operationSignal), + call.input, + signal, + ); } } + #executeTracing( + name: "trace_feature" | "find_code_for_string" | "trace_call_path", + input: unknown, + signal?: AbortSignal, + ): EnhancedResult { + const parsed = enhancedInputSchemas[name].safeParse(input); + if (!parsed.success) return invalidEnhancedInput(name, parsed.error); + if (name === "trace_call_path") + return this.executeValidated( + { + name, + input: enhancedInputSchemas.trace_call_path.parse(parsed.data), + }, + signal, + ); + return this.executeValidated( + { + name, + input: enhancedInputSchemas[name].parse(parsed.data), + }, + signal, + ); + } + async #analyzeFunction( input: Readonly>, signal?: AbortSignal, @@ -346,161 +390,6 @@ export class EnhancedTools { }); } - async #traceFeature( - input: { - readonly query: string; - readonly case_sensitive: boolean; - readonly limit: number; - readonly max_operations: number; - }, - signal?: AbortSignal, - ): EnhancedResult { - const searched = await this.#literalMatches(input, signal); - if (!searched.ok) return searched; - const residual = new Set(); - const traced = await this.#traceReferences( - searched.value.matches, - input.limit, - input.max_operations - searched.value.operations, - signal, - ); - if (!traced.ok) return traced; - for (const reason of traced.value.residual) residual.add(reason); - const operations = searched.value.operations + traced.value.operations; - if (operations >= input.max_operations) - residual.add("Investigation reached the operation budget."); - if (searched.value.matches.length >= input.limit) - residual.add("Literal matches reached the configured limit."); - return ok({ - query: input.query, - search_mode: "literal", - operations_used: operations, - operation_budget: input.max_operations, - matches: searched.value.matches, - references: traced.value.references, - truncated: residual.size > 0, - residual_unknowns: [...residual], - }); - } - - async #literalMatches( - input: { - readonly query: string; - readonly case_sensitive: boolean; - readonly limit: number; - readonly max_operations: number; - }, - signal?: AbortSignal, - ): Promise< - Result<{ matches: TraceMatch[]; operations: number }, AnalysisError> - > { - let operations = 0; - const matches: TraceMatch[] = []; - const needle = input.case_sensitive - ? input.query - : input.query.toLowerCase(); - for (const [tool, type] of [ - ["list_strings", "string"], - ["list_procedures", "procedure"], - ] as const) { - let offset = 0; - while ( - operations < input.max_operations && - matches.length < input.limit - ) { - operations += 1; - const result = await this.#call(tool, { offset, limit: 500 }, signal); - if (!result.ok) return result; - const page = parseAddressedPage(result.value); - if (!page.ok) return page; - for (const item of page.value.items) { - const haystack = input.case_sensitive - ? item.name - : item.name.toLowerCase(); - if (haystack.includes(needle)) - matches.push({ type, address: item.address, value: item.name }); - if (matches.length >= input.limit) break; - } - if (!page.value.hasMore || page.value.nextOffset === null) break; - offset = page.value.nextOffset; - } - } - return ok({ matches, operations }); - } - - async #traceReferences( - matches: readonly TraceMatch[], - limit: number, - operationBudget: number, - signal?: AbortSignal, - ): Promise< - Result< - { references: TraceReference[]; operations: number; residual: string[] }, - AnalysisError - > - > { - let operations = 0; - const references: TraceReference[] = []; - const residual = new Set(); - for (const match of matches) { - if (operations >= operationBudget) { - residual.add("Reference traversal stopped at the operation budget."); - break; - } - operations += 1; - const xrefs = await this.#call( - "xrefs", - { address: match.address }, - signal, - ); - if (!xrefs.ok) return xrefs; - if (!Array.isArray(xrefs.value)) - return err( - new AnalysisOutputError( - "xrefs", - "provider returned a non-array result", - ), - ); - for (const source of xrefs.value) { - if (typeof source !== "string") - return err( - new AnalysisOutputError( - "xrefs", - "provider returned a non-address value", - ), - ); - if (operations >= operationBudget) { - residual.add( - "Containing-procedure resolution stopped at the operation budget.", - ); - break; - } - operations += 1; - const resolved = await this.#call( - "resolve_containing_procedure", - { address: source }, - signal, - ); - if (!resolved.ok) return resolved; - references.push({ - target_address: match.address, - source_address: source, - containing_procedure: resolved.value, - }); - if (references.length >= limit) { - residual.add("Reference results reached the configured limit."); - break; - } - } - if (references.length >= limit) break; - } - return ok({ - references, - operations, - residual: [...residual], - }); - } - async #allAddressed( tool: "list_names" | "list_procedures", signal?: AbortSignal, diff --git a/src/application/JavaScriptApplicationWorkflowEvidence.ts b/src/application/JavaScriptApplicationWorkflowEvidence.ts index 1101b5b6a..a87b0a0f0 100644 --- a/src/application/JavaScriptApplicationWorkflowEvidence.ts +++ b/src/application/JavaScriptApplicationWorkflowEvidence.ts @@ -8,6 +8,7 @@ import type { JavaScriptExportShapeComparisonResult } from "../domain/javascript import type { ApplicationFeatureTraceResult } from "../domain/javascriptFeatureTraceSchemas.js"; import type { JavaScriptSemanticTraceResult } from "../domain/javascriptSemanticTraceSchemas.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; +import type { SourceToBundleComparisonResult } from "../domain/sourceToBundleComparisonSchemas.js"; import { JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER } from "./InvestigationProviders.js"; /** Create derived Evidence for one bounded application-graph feature trace. */ @@ -64,6 +65,24 @@ export const createApplicationVersionComparisonEvidence = ( evidenceLinks: result.evidence_links, }); +/** Create derived Evidence for one historical-source to bundle comparison. */ +export const createSourceToBundleComparisonEvidence = ( + parameters: EvidenceObservation["parameters"], + result: SourceToBundleComparisonResult, +): Evidence => + createEvidence(undefined, JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER, { + predicateType: "rea.source-to-bundle-comparison/v1", + operation: "compare_source_to_bundle", + parameters, + result: jsonValueSchema.parse(result), + rawResult: null, + confidence: "inferred", + authority: "analyst-inference", + environment: null, + limitations: result.limitations, + evidenceLinks: result.evidence_links, + }); + /** Create derived Evidence for one bounded static export-shape comparison. */ export const createJavaScriptExportShapeComparisonEvidence = ( parameters: EvidenceObservation["parameters"], diff --git a/src/application/JavaScriptApplicationWorkflowService.ts b/src/application/JavaScriptApplicationWorkflowService.ts index dceb1f7e6..29f23173f 100644 --- a/src/application/JavaScriptApplicationWorkflowService.ts +++ b/src/application/JavaScriptApplicationWorkflowService.ts @@ -14,6 +14,8 @@ import { compareJavaScriptExportShapesInputSchema } from "../domain/javascriptEx import { traceApplicationFeature } from "../domain/javascriptFeatureTrace.js"; import { traceApplicationFeatureInputSchema } from "../domain/javascriptFeatureTraceSchemas.js"; import { err, ok, type Result } from "../domain/result.js"; +import { compareSourceToBundle } from "../domain/sourceToBundleComparison.js"; +import { compareSourceToBundleInputSchema } from "../domain/sourceToBundleComparisonSchemas.js"; import { parseApplicationGraphEvidence, parseNativeApplicationEvidence, @@ -22,6 +24,7 @@ import { createApplicationFeatureTraceEvidence, createApplicationVersionComparisonEvidence, createJavaScriptExportShapeComparisonEvidence, + createSourceToBundleComparisonEvidence, } from "./JavaScriptApplicationWorkflowEvidence.js"; /** Authenticate Evidence and derive one bounded cross-layer feature trace. */ @@ -141,6 +144,37 @@ export const compareApplicationVersionsEvidenceValidated = ( } }; +/** Compare source and bundle inputs already parsed by a trusted adapter. */ +export const compareSourceToBundleEvidenceValidated = ( + input: z.output, +): Result => { + const operation = "compare_source_to_bundle"; + try { + const application = parseApplicationGraphEvidence(input.application); + const result = compareSourceToBundle({ + reference: input.reference, + application: { + evidenceId: application.evidence.evidence_id, + rootArtifactSha256: application.rootArtifactSha256, + graph: application.graph, + }, + limits: input.limits, + }); + return ok( + createSourceToBundleComparisonEvidence( + { + reference_root_sha256: input.reference.root_sha256, + application_evidence_id: application.evidence.evidence_id, + limits: input.limits, + }, + result, + ), + ); + } catch (cause: unknown) { + return workflowFailure(operation, cause); + } +}; + /** * Authenticate both graphs and compare one exact export return shape. * @public diff --git a/src/application/JavaScriptRuntimeObservationEvidence.ts b/src/application/JavaScriptRuntimeObservationEvidence.ts new file mode 100644 index 000000000..4fffdc9a0 --- /dev/null +++ b/src/application/JavaScriptRuntimeObservationEvidence.ts @@ -0,0 +1,60 @@ +import type { ProviderIdentity } from "./AnalysisProvider.js"; +import { + createEvidence, + type Evidence, + type EvidenceObservation, +} from "../domain/evidence.js"; +import { jsonValueSchema } from "../domain/jsonValue.js"; +import type { + JavaScriptRuntimeObservation, + JavaScriptRuntimeTargetList, + ListJavaScriptRuntimeTargetsInput, + ObserveJavaScriptRuntimeInput, +} from "../domain/javascriptRuntimeObservation.js"; + +type RuntimeObservationOperation = + | "list_javascript_runtime_targets" + | "observe_javascript_runtime"; + +/** Create deterministic Evidence v2 for one passive Inspector operation. */ +export const createJavaScriptRuntimeObservationEvidence = ( + operation: RuntimeObservationOperation, + input: ListJavaScriptRuntimeTargetsInput | ObserveJavaScriptRuntimeInput, + result: JavaScriptRuntimeTargetList | JavaScriptRuntimeObservation, + provider: ProviderIdentity, +): Evidence => + createEvidence(undefined, provider, { + predicateType: + operation === "list_javascript_runtime_targets" + ? "rea.javascript-runtime-target-list/v1" + : "rea.javascript-runtime-observation/v1", + operation, + parameters: parameters(input), + result: jsonValueSchema.parse(result), + rawResult: null, + confidence: "observed", + authority: "external-service", + environment: { + id: `${result.runtime.product}@${result.runtime.protocol_version}`, + platform: process.platform, + architecture: process.arch, + isolation: "none", + }, + limitations: result.limitations, + }); + +const parameters = ( + input: ListJavaScriptRuntimeTargetsInput | ObserveJavaScriptRuntimeInput, +): EvidenceObservation["parameters"] => ({ + inspector_endpoint: input.inspector_endpoint, + allowed_file_roots: input.allowed_file_roots, + allowed_origins: input.allowed_origins, + ...("target_id" in input + ? { + target_id: input.target_id, + runtime_kind: input.runtime_kind, + observation_ms: input.observation_ms, + limits: input.limits, + } + : { offset: input.offset, limit: input.limit }), +}); diff --git a/src/application/JavaScriptRuntimeObservationPort.ts b/src/application/JavaScriptRuntimeObservationPort.ts new file mode 100644 index 000000000..7bb3cfec2 --- /dev/null +++ b/src/application/JavaScriptRuntimeObservationPort.ts @@ -0,0 +1,22 @@ +import type { ExecutionOptions, ProviderIdentity } from "./AnalysisProvider.js"; +import type { + JavaScriptRuntimeObservation, + JavaScriptRuntimeTargetList, + ListJavaScriptRuntimeTargetsInput, + ObserveJavaScriptRuntimeInput, +} from "../domain/javascriptRuntimeObservation.js"; +import type { AnalysisError } from "../domain/errors.js"; +import type { Result } from "../domain/result.js"; + +/** Provider-neutral boundary for passive Node/Electron Inspector observation. */ +export interface JavaScriptRuntimeObservationPort { + identity(): ProviderIdentity; + listTargets( + input: ListJavaScriptRuntimeTargetsInput, + options?: ExecutionOptions, + ): Promise>; + observe( + input: ObserveJavaScriptRuntimeInput, + options?: ExecutionOptions, + ): Promise>; +} diff --git a/src/application/JavaScriptRuntimeObservationService.ts b/src/application/JavaScriptRuntimeObservationService.ts new file mode 100644 index 000000000..3deda9155 --- /dev/null +++ b/src/application/JavaScriptRuntimeObservationService.ts @@ -0,0 +1,116 @@ +import type { ExecutionOptions } from "./AnalysisProvider.js"; +import type { JavaScriptRuntimeObservationPort } from "./JavaScriptRuntimeObservationPort.js"; +import type { PermissionAuthority } from "./PermissionAuthority.js"; +import { createJavaScriptRuntimeObservationEvidence } from "./JavaScriptRuntimeObservationEvidence.js"; +import type { Evidence } from "../domain/evidence.js"; +import { + AnalysisCapabilityUnavailableError, + AnalysisProtocolError, + PermissionRequiredError, + type AnalysisError, +} from "../domain/errors.js"; +import type { + ListJavaScriptRuntimeTargetsInput, + ObserveJavaScriptRuntimeInput, +} from "../domain/javascriptRuntimeObservation.js"; +import { err, ok, type Result } from "../domain/result.js"; + +/** Authorize and list attachable Node/Electron Inspector targets. */ +export const listJavaScriptRuntimeTargets = async ( + provider: JavaScriptRuntimeObservationPort | undefined, + authority: PermissionAuthority | undefined, + input: ListJavaScriptRuntimeTargetsInput, + options: ExecutionOptions = {}, +): Promise> => { + const ready = await prepare( + provider, + authority, + input, + "list_javascript_runtime_targets", + ); + if (!ready.ok) return ready; + const result = await ready.value.listTargets(input, options); + return result.ok + ? ok( + createJavaScriptRuntimeObservationEvidence( + "list_javascript_runtime_targets", + input, + result.value, + ready.value.identity(), + ), + ) + : result; +}; + +/** Authorize one bounded attach-only Inspector observation. */ +export const observeJavaScriptRuntime = async ( + provider: JavaScriptRuntimeObservationPort | undefined, + authority: PermissionAuthority | undefined, + input: ObserveJavaScriptRuntimeInput, + options: ExecutionOptions = {}, +): Promise> => { + const ready = await prepare( + provider, + authority, + input, + "observe_javascript_runtime", + ); + if (!ready.ok) return ready; + const result = await ready.value.observe(input, options); + return result.ok + ? ok( + createJavaScriptRuntimeObservationEvidence( + "observe_javascript_runtime", + input, + result.value, + ready.value.identity(), + ), + ) + : result; +}; + +const prepare = async ( + provider: JavaScriptRuntimeObservationPort | undefined, + authority: PermissionAuthority | undefined, + input: ListJavaScriptRuntimeTargetsInput | ObserveJavaScriptRuntimeInput, + operation: "list_javascript_runtime_targets" | "observe_javascript_runtime", +): Promise> => { + if (authority === undefined) + return err( + new AnalysisCapabilityUnavailableError( + "rea-v8-inspector", + operation, + "V8 Inspector observation permission policy is not configured", + ), + ); + const authorized = await authority.authorize( + { + capability: "v8_inspector_observe", + roots: input.allowed_file_roots, + executables: [], + environment_names: [], + origins: [input.inspector_endpoint, ...input.allowed_origins], + network: "loopback", + mount: false, + operation_identity: `${operation}:${"target_id" in input ? input.target_id : input.inspector_endpoint}`, + }, + "read", + ); + if (!authorized.ok) + return err( + authorized.error instanceof PermissionRequiredError + ? authorized.error + : new AnalysisProtocolError(authorized.error.message, { + cause: authorized.error, + }), + ); + return provider === undefined + ? err( + new AnalysisCapabilityUnavailableError( + "rea-v8-inspector", + operation, + "V8 Inspector observation provider is not configured", + ), + ) + : ok(provider); +}; diff --git a/src/application/JavaScriptSemanticGraphAsyncProjection.ts b/src/application/JavaScriptSemanticGraphAsyncProjection.ts new file mode 100644 index 000000000..34776b55c --- /dev/null +++ b/src/application/JavaScriptSemanticGraphAsyncProjection.ts @@ -0,0 +1,236 @@ +import { createJavaScriptSemanticGraphUnknown } from "../domain/javascriptSemanticGraph.js"; +import type { JavaScriptSemanticGraphNode } from "../domain/javascriptSemanticGraph.js"; +import type { + JavaScriptSemanticEventOperation, + JavaScriptSemanticTimerOperation, +} from "../domain/javascriptSemanticIr.js"; +import { + addSemanticGraphNode, + addSemanticGraphRelation, + addSemanticGraphUnknown, + constructSemanticGraphNode, +} from "./JavaScriptSemanticGraphConstruction.js"; +import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js"; +import { + semanticCallSiteAt, + type SemanticFlowProjectionContext, +} from "./JavaScriptSemanticGraphFlowProjection.js"; + +/** Project literal EventEmitter registrations, removals, and dispatches. */ +export const projectSemanticEvents = ( + context: SemanticFlowProjectionContext, +): void => { + const eventNodes = new Map(); + for (const operation of context.ir.eventOperations) { + const eventKey = + operation.eventName === null + ? operation.eventId + : `${operation.emitterKey}\u0000${operation.eventName}`; + const event = + eventNodes.get(eventKey) ?? addEventNode(context, operation, eventKey); + if (event === null) continue; + eventNodes.set(eventKey, event); + const relation = eventRelation(operation); + if (operation.kind === "dispatch") + addSemanticGraphRelation(context.state, { + source: eventEmitterNode(context, operation), + target: event, + relation, + resolution: + operation.resolution === "complete" ? "resolved" : "candidate", + }); + else { + const listener = addListenerNode(context, operation); + addSemanticGraphRelation(context.state, { + source: event, + target: listener, + relation, + resolution: + operation.resolution === "complete" ? "resolved" : "candidate", + }); + } + if (operation.resolution !== "complete") + addEventUnknown(context, operation, event, relation); + } +}; + +const addEventNode = ( + context: SemanticFlowProjectionContext, + operation: JavaScriptSemanticEventOperation, + eventKey: string, +): JavaScriptSemanticGraphNode | null => + addSemanticGraphNode( + context.state, + constructSemanticGraphNode( + context.file, + { + kind: "event", + roleKey: `event:${eventKey}`, + location: operation.eventName === null ? operation.location : null, + label: operation.eventName, + functionNodeId: null, + properties: { + emitter_key: operation.emitterKey, + event_name: operation.eventName, + }, + }, + context.state, + ), + ); + +const addListenerNode = ( + context: SemanticFlowProjectionContext, + operation: JavaScriptSemanticEventOperation, +): JavaScriptSemanticGraphNode | null => + addSemanticGraphNode( + context.state, + constructSemanticGraphNode( + context.file, + { + kind: "listener", + roleKey: `listener:${operation.eventId}`, + location: operation.listenerLocation ?? operation.location, + label: + operation.eventName === null + ? operation.method + : `${operation.method}:${operation.eventName}`, + functionNodeId: + operation.ownerCallableId === null + ? null + : (context.callableNodes.get(operation.ownerCallableId)?.node_id ?? + null), + properties: { + emitter_key: operation.emitterKey, + event_name: operation.eventName, + method: operation.method, + }, + }, + context.state, + ), + ); + +const eventEmitterNode = ( + context: SemanticFlowProjectionContext, + operation: JavaScriptSemanticEventOperation, +): JavaScriptSemanticGraphNode => + (operation.emitterBindingId === null + ? undefined + : context.bindingNodes.get(operation.emitterBindingId)) ?? + (operation.ownerCallableId === null + ? context.moduleNode + : (context.callableNodes.get(operation.ownerCallableId) ?? + context.moduleNode)); + +const eventRelation = ( + operation: JavaScriptSemanticEventOperation, +): "registers-listener" | "removes-listener" | "dispatches-candidate" => + operation.kind === "register" + ? "registers-listener" + : operation.kind === "remove" + ? "removes-listener" + : "dispatches-candidate"; + +const addEventUnknown = ( + context: SemanticFlowProjectionContext, + operation: JavaScriptSemanticEventOperation, + event: JavaScriptSemanticGraphNode, + relation: ReturnType, +): void => { + addSemanticGraphUnknown( + context.state, + createJavaScriptSemanticGraphUnknown({ + node_id: event.node_id, + family: "event", + relation_kinds: [relation], + reason: "ambiguous-target", + detail: + operation.eventName === null + ? `Dynamic ${operation.method} event name remains unresolved.` + : `Static ${operation.method} emitter identity is partial.`, + candidate_node_ids: [], + evidence: unknownSemanticEvidence(context.file, operation.location), + }), + ); +}; + +/** Project Node timer scheduling and exact local handle cancellation. */ +export const projectSemanticTimers = ( + context: SemanticFlowProjectionContext, +): void => { + const timerNodes = new Map( + context.ir.timerOperations.flatMap((operation) => { + if (operation.kind !== "schedule") return []; + const owner = + operation.ownerCallableId === null + ? undefined + : context.callableNodes.get(operation.ownerCallableId); + const timer = addSemanticGraphNode( + context.state, + constructSemanticGraphNode( + context.file, + { + kind: "timer", + roleKey: operation.timerId, + location: operation.location, + label: operation.method, + functionNodeId: owner?.node_id ?? null, + properties: { + delay_milliseconds: operation.delayMilliseconds, + method: operation.method, + }, + }, + context.state, + ), + ); + return timer === null ? [] : [[operation.timerId, timer] as const]; + }), + ); + for (const operation of context.ir.timerOperations) + if (operation.kind === "schedule") + projectTimerSchedule(context, operation, timerNodes); + else projectTimerCancellation(context, operation, timerNodes); +}; + +const projectTimerSchedule = ( + context: SemanticFlowProjectionContext, + operation: JavaScriptSemanticTimerOperation, + timerNodes: ReadonlyMap, +): void => { + addSemanticGraphRelation(context.state, { + source: semanticCallSiteAt(context, operation.location), + target: timerNodes.get(operation.timerId), + relation: "schedules-timer", + resolution: "resolved", + }); +}; + +const projectTimerCancellation = ( + context: SemanticFlowProjectionContext, + operation: JavaScriptSemanticTimerOperation, + timerNodes: ReadonlyMap, +): void => { + const callSite = semanticCallSiteAt(context, operation.location); + const target = + operation.linkedTimerId === null + ? undefined + : timerNodes.get(operation.linkedTimerId); + addSemanticGraphRelation(context.state, { + source: callSite, + target, + relation: "cancels-timer", + resolution: operation.resolution === "complete" ? "resolved" : "candidate", + }); + if (operation.resolution === "complete") return; + addSemanticGraphUnknown( + context.state, + createJavaScriptSemanticGraphUnknown({ + node_id: callSite?.node_id ?? null, + family: "timer", + relation_kinds: ["cancels-timer"], + reason: "ambiguous-target", + detail: `Static ${operation.method} handle resolution was ${operation.resolution}.`, + candidate_node_ids: [], + evidence: unknownSemanticEvidence(context.file, operation.location), + }), + ); +}; diff --git a/src/application/JavaScriptSemanticGraphBuilder.ts b/src/application/JavaScriptSemanticGraphBuilder.ts index c04e4adc9..069cec254 100644 --- a/src/application/JavaScriptSemanticGraphBuilder.ts +++ b/src/application/JavaScriptSemanticGraphBuilder.ts @@ -11,13 +11,29 @@ import type { JavaScriptSemanticCallArgument, JavaScriptSemanticIr, } from "../domain/javascriptSemanticIr.js"; +import { sourceRangesEqual as rangesEqual } from "../domain/javascriptStaticAnalysisHelpers.js"; import type { JavaScriptApplicationGraph } from "../domain/javascriptApplicationGraph.js"; import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js"; import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js"; +import { + projectSemanticEvents, + projectSemanticTimers, +} from "./JavaScriptSemanticGraphAsyncProjection.js"; +import { projectSemanticChildProcesses } from "./JavaScriptSemanticGraphChildProcessProjection.js"; +import { + projectSemanticBoundaries, + projectSemanticConfiguration, + projectSemanticRequests, +} from "./JavaScriptSemanticGraphDataProjection.js"; +import { projectSemanticResources } from "./JavaScriptSemanticGraphResourceProjection.js"; +import { projectSemanticObjects } from "./JavaScriptSemanticGraphObjectProjection.js"; +import { projectSemanticValues } from "./JavaScriptSemanticGraphValueProjection.js"; import { inferredSemanticEvidenceAt } from "./JavaScriptSemanticGraphEvidence.js"; +import { projectSemanticFunctionFingerprints } from "./JavaScriptSemanticGraphFingerprintProjection.js"; import { projectSemanticClosureCaptures, projectSemanticFrontiers, + projectSemanticPromises, projectSemanticReturnValues, semanticRecoveryLimits, type SemanticFlowProjectionContext, @@ -26,7 +42,6 @@ import { owningSemanticCallableNode, semanticFamilyStatus, semanticNodesWithinRange, - semanticRangesEqual as rangesEqual, } from "./JavaScriptSemanticGraphProjection.js"; import { addSemanticFallbackRoot as addFallbackRoot, @@ -71,9 +86,12 @@ export const buildJavaScriptSemanticGraph = ({ analysis, }: BuilderInput): JavaScriptSemanticGraph => { const state = emptyState(applicationGraph); + const fingerprints: JavaScriptSemanticGraph["fingerprints"][number][] = []; for (const analyzed of analysis.files) { if (analyzed.semantic === null) continue; - projectFile(analyzed.file, analyzed.semantic.ir, state); + fingerprints.push( + ...projectFile(analyzed.file, analyzed.semantic.ir, state), + ); } if (state.roots.size === 0) addFallbackRoot(rootArtifactSha256, state); const semanticTruncated = analysis.files.some( @@ -89,7 +107,7 @@ export const buildJavaScriptSemanticGraph = ({ root_node_ids: [...state.roots], nodes: [...state.nodes.values()], relations: [...state.relations.values()], - fingerprints: [], + fingerprints, unknowns, coverage: { status: truncated ? "partial" : "unknown", @@ -135,7 +153,15 @@ export const buildJavaScriptSemanticGraph = ({ limitations: [ "The semantic graph contains static syntax observations and conservative relationship candidates; it does not claim runtime execution.", "Local data flow does not claim control-flow-sensitive reaching definitions or arbitrary dynamic property resolution.", - "Function fingerprints and promise, event, process, request, boundary, timer, configuration, and resource extraction are not available in this graph version's current extractor coverage.", + "Promise ownership covers explicit unshadowed Promise construction, static factories, aggregation, chaining, and await syntax only.", + "Function fingerprints are bounded static candidates; equal digests can remain ambiguous and do not prove behavioral equivalence.", + "Event extraction covers EventEmitter-style literal registrations, removals, and dispatch candidates; dynamic names remain unknown.", + "Timer extraction covers global or node:timers scheduling and exact local-handle cancellation.", + "Child-process extraction covers asynchronous node:child_process creation, literal argv/env/stdio options, exit/error listeners, and kill signals.", + "Configuration extraction covers process.env, process.argv, node:fs reads, and direct logical defaults.", + "Request extraction covers fetch, WebSocket, node:http/node:https construction, direct option fields, and exact local response consumers.", + "Boundary extraction covers unshadowed JSON/global coercions plus parse and validation method candidates.", + "Resource extraction covers built-in filesystem/network acquisition and exact local close/destroy/end handles.", ...(truncated ? [ "Semantic graph projection reached one or more explicit hard limits.", @@ -149,7 +175,7 @@ const projectFile = ( file: JavaScriptArtifactFile, ir: JavaScriptSemanticIr, state: BuilderState, -): void => { +): JavaScriptSemanticGraph["fingerprints"][number][] => { const moduleNode = addNode( state, semanticNode( @@ -164,7 +190,7 @@ const projectFile = ( state, ), ); - if (moduleNode === null) return; + if (moduleNode === null) return []; state.roots.add(moduleNode.node_id); const callableNodes = new Map( ir.callables.flatMap((callable) => { @@ -228,9 +254,20 @@ const projectFile = ( ), }; projectDefinitionsAndReferences(context); + projectSemanticValues(context); + projectSemanticObjects(context); projectCalls(context); + projectSemanticPromises(context); + projectSemanticEvents(context); + projectSemanticTimers(context); + projectSemanticChildProcesses(context); + projectSemanticConfiguration(context); + projectSemanticRequests(context); + projectSemanticBoundaries(context); + projectSemanticResources(context); projectSemanticClosureCaptures(context); projectSemanticFrontiers(context); + return projectSemanticFunctionFingerprints(file, ir, callableNodes); }; const createReturnSiteNodes = ( diff --git a/src/application/JavaScriptSemanticGraphChildProcessProjection.ts b/src/application/JavaScriptSemanticGraphChildProcessProjection.ts new file mode 100644 index 000000000..d4e08b5ec --- /dev/null +++ b/src/application/JavaScriptSemanticGraphChildProcessProjection.ts @@ -0,0 +1,257 @@ +import { createJavaScriptSemanticGraphUnknown } from "../domain/javascriptSemanticGraph.js"; +import type { JavaScriptSemanticGraphNode } from "../domain/javascriptSemanticGraph.js"; +import type { + JavaScriptSemanticChildProcessInteraction, + JavaScriptSemanticChildProcessSpawn, +} from "../domain/javascriptSemanticIr.js"; +import { + addSemanticGraphNode, + addSemanticGraphRelation, + addSemanticGraphUnknown, + constructSemanticGraphNode, +} from "./JavaScriptSemanticGraphConstruction.js"; +import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js"; +import { + semanticCallSiteAt, + type SemanticFlowProjectionContext, +} from "./JavaScriptSemanticGraphFlowProjection.js"; + +interface ChildUnknownInput { + readonly context: SemanticFlowProjectionContext; + readonly node: JavaScriptSemanticGraphNode | null; + readonly location: JavaScriptSemanticChildProcessSpawn["location"]; + readonly relationKinds: readonly ( + | "spawns" + | "forwards-signal" + | "listens-exit" + | "listens-error" + )[]; + readonly detail: string; + readonly candidateNodeIds: readonly string[]; +} + +/** Project child creation, argv/env/stdio, listeners, and signals. */ +export const projectSemanticChildProcesses = ( + context: SemanticFlowProjectionContext, +): void => { + const processNodes = new Map( + context.ir.childProcessSpawns.flatMap((spawn) => { + const node = addChildProcessNode(context, spawn); + return node === null ? [] : [[spawn.processId, node] as const]; + }), + ); + for (const spawn of context.ir.childProcessSpawns) { + const child = processNodes.get(spawn.processId); + if (child === undefined) continue; + projectSpawn(context, spawn, child); + } + for (const interaction of context.ir.childProcessInteractions) + projectInteraction(context, interaction, processNodes); +}; + +const addChildProcessNode = ( + context: SemanticFlowProjectionContext, + spawn: JavaScriptSemanticChildProcessSpawn, +): JavaScriptSemanticGraphNode | null => + addSemanticGraphNode( + context.state, + constructSemanticGraphNode( + context.file, + { + kind: "child-process", + roleKey: spawn.processId, + location: spawn.location, + label: spawn.command ?? spawn.method, + functionNodeId: + spawn.ownerCallableId === null + ? null + : (context.callableNodes.get(spawn.ownerCallableId)?.node_id ?? + null), + properties: { + argv_count: spawn.argvCount, + command: spawn.command, + environment_supplied: spawn.environmentSupplied, + method: spawn.method, + stdio_mode: spawn.stdioMode, + }, + }, + context.state, + ), + ); + +const projectSpawn = ( + context: SemanticFlowProjectionContext, + spawn: JavaScriptSemanticChildProcessSpawn, + child: JavaScriptSemanticGraphNode, +): void => { + const callSite = semanticCallSiteAt(context, spawn.location); + addSemanticGraphRelation(context.state, { + source: callSite, + target: child, + relation: "spawns", + resolution: spawn.resolution === "complete" ? "resolved" : "candidate", + }); + if (spawn.argvCount !== null) + addSemanticGraphRelation(context.state, { + source: callSite, + target: child, + relation: "supplies-argv", + resolution: "resolved", + properties: { argument_count: spawn.argvCount }, + }); + if (spawn.environmentSupplied) + addSemanticGraphRelation(context.state, { + source: callSite, + target: child, + relation: "supplies-env", + resolution: "resolved", + }); + const stdio = addStdioNode(context, spawn, child); + addSemanticGraphRelation(context.state, { + source: child, + target: stdio, + relation: "connects-stdio", + resolution: spawn.stdioMode === "dynamic" ? "candidate" : "resolved", + }); + if (spawn.resolution !== "complete") + addChildUnknown({ + context, + node: child, + location: spawn.location, + relationKinds: ["spawns"], + detail: `Static ${spawn.method} command remains unresolved.`, + candidateNodeIds: [], + }); +}; + +const addStdioNode = ( + context: SemanticFlowProjectionContext, + spawn: JavaScriptSemanticChildProcessSpawn, + child: JavaScriptSemanticGraphNode, +): JavaScriptSemanticGraphNode | null => + addSemanticGraphNode( + context.state, + constructSemanticGraphNode( + context.file, + { + kind: "stdio", + roleKey: `stdio:${spawn.processId}`, + location: spawn.location, + label: spawn.stdioMode, + functionNodeId: child.function_node_id, + properties: { mode: spawn.stdioMode }, + }, + context.state, + ), + ); + +const projectInteraction = ( + context: SemanticFlowProjectionContext, + interaction: JavaScriptSemanticChildProcessInteraction, + processNodes: ReadonlyMap, +): void => { + const children = interaction.linkedProcessIds.flatMap((identifier) => { + const child = processNodes.get(identifier); + return child === undefined ? [] : [child]; + }); + const effect = + interaction.kind === "listener" + ? addChildListenerNode(context, interaction) + : addSignalNode(context, interaction); + for (const child of children) + addSemanticGraphRelation(context.state, { + source: interaction.kind === "listener" ? child : effect, + target: interaction.kind === "listener" ? effect : child, + relation: + interaction.kind === "signal" + ? "forwards-signal" + : interaction.eventName === "exit" + ? "listens-exit" + : "listens-error", + resolution: + interaction.resolution === "complete" ? "resolved" : "candidate", + }); + if (interaction.resolution !== "complete") + addChildUnknown({ + context, + node: effect, + location: interaction.location, + relationKinds: [ + interaction.kind === "signal" + ? "forwards-signal" + : interaction.eventName === "exit" + ? "listens-exit" + : "listens-error", + ], + detail: `Static child ${interaction.method} ownership is ambiguous.`, + candidateNodeIds: children.map(({ node_id: identifier }) => identifier), + }); +}; + +const addChildListenerNode = ( + context: SemanticFlowProjectionContext, + interaction: JavaScriptSemanticChildProcessInteraction, +): JavaScriptSemanticGraphNode | null => + addSemanticGraphNode( + context.state, + constructSemanticGraphNode( + context.file, + { + kind: "listener", + roleKey: interaction.interactionId, + location: interaction.listenerLocation ?? interaction.location, + label: interaction.eventName, + functionNodeId: + interaction.ownerCallableId === null + ? null + : (context.callableNodes.get(interaction.ownerCallableId) + ?.node_id ?? null), + properties: { + event_name: interaction.eventName, + method: interaction.method, + }, + }, + context.state, + ), + ); + +const addSignalNode = ( + context: SemanticFlowProjectionContext, + interaction: JavaScriptSemanticChildProcessInteraction, +): JavaScriptSemanticGraphNode | null => + addSemanticGraphNode( + context.state, + constructSemanticGraphNode( + context.file, + { + kind: "signal", + roleKey: interaction.interactionId, + location: interaction.location, + label: interaction.signalName, + functionNodeId: + interaction.ownerCallableId === null + ? null + : (context.callableNodes.get(interaction.ownerCallableId) + ?.node_id ?? null), + properties: { signal_name: interaction.signalName }, + }, + context.state, + ), + ); + +const addChildUnknown = (input: ChildUnknownInput): void => { + const { context, node, location, relationKinds, detail, candidateNodeIds } = + input; + addSemanticGraphUnknown( + context.state, + createJavaScriptSemanticGraphUnknown({ + node_id: node?.node_id ?? null, + family: "child-process", + relation_kinds: relationKinds, + reason: "ambiguous-target", + detail, + candidate_node_ids: candidateNodeIds, + evidence: unknownSemanticEvidence(context.file, location), + }), + ); +}; diff --git a/src/application/JavaScriptSemanticGraphConstruction.ts b/src/application/JavaScriptSemanticGraphConstruction.ts index b2614afd9..0c5b44b4c 100644 --- a/src/application/JavaScriptSemanticGraphConstruction.ts +++ b/src/application/JavaScriptSemanticGraphConstruction.ts @@ -1,4 +1,5 @@ import type { JavaScriptApplicationGraph } from "../domain/javascriptApplicationGraph.js"; +import type { JsonValue } from "../domain/jsonValue.js"; import { createJavaScriptSemanticGraphNode, createJavaScriptSemanticGraphRelation, @@ -49,6 +50,7 @@ export interface SemanticNodeConstructionInput { readonly location: JavaScriptSourceRange | null; readonly label: string | null; readonly functionNodeId: string | null; + readonly properties?: Readonly>; } /** Input for one directed static semantic relationship. */ @@ -58,6 +60,7 @@ export interface SemanticRelationConstructionInput { readonly relation: JavaScriptSemanticGraphRelation["relation"]; readonly resolution?: JavaScriptSemanticGraphRelation["resolution"]; readonly evidence?: ApplicationGraphEvidence; + readonly properties?: Readonly>; } /** Create empty projection state with structural application-node mappings. */ @@ -94,7 +97,7 @@ export const constructSemanticGraphNode = ( function_node_id: input.functionNodeId, application_node_ids: matchingApplicationNodeIds(file, input, state), label: input.label, - properties: {}, + properties: input.properties ?? {}, evidence: observedSemanticEvidence(file, input.location), }); @@ -131,7 +134,7 @@ export const addSemanticGraphRelation = ( target_node_id: input.target.node_id, relation: input.relation, resolution: input.resolution ?? "candidate", - properties: {}, + properties: input.properties ?? {}, evidence: input.evidence ?? inferredSemanticEvidence(input.source), }); if (state.relations.has(value.relation_id)) return; diff --git a/src/application/JavaScriptSemanticGraphDataProjection.ts b/src/application/JavaScriptSemanticGraphDataProjection.ts new file mode 100644 index 000000000..e56f1e362 --- /dev/null +++ b/src/application/JavaScriptSemanticGraphDataProjection.ts @@ -0,0 +1,333 @@ +import { createJavaScriptSemanticGraphUnknown } from "../domain/javascriptSemanticGraph.js"; +import type { JavaScriptSemanticGraphNode } from "../domain/javascriptSemanticGraph.js"; +import type { + JavaScriptSemanticBoundaryOperation, + JavaScriptSemanticConfigurationOperation, + JavaScriptSemanticRequestOperation, +} from "../domain/javascriptSemanticIr.js"; +import { + addSemanticGraphNode, + addSemanticGraphRelation, + addSemanticGraphUnknown, + constructSemanticGraphNode, +} from "./JavaScriptSemanticGraphConstruction.js"; +import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js"; +import { + semanticCallSiteAt, + type SemanticFlowProjectionContext, +} from "./JavaScriptSemanticGraphFlowProjection.js"; + +/** Project environment, argv, file, default, and precedence candidates. */ +export const projectSemanticConfiguration = ( + context: SemanticFlowProjectionContext, +): void => { + const configNodes = new Map( + context.ir.configurationOperations.flatMap((operation) => { + const node = addConfigurationNode(context, operation); + return node === null ? [] : [[operation.configId, node] as const]; + }), + ); + for (const operation of context.ir.configurationOperations) { + const config = configNodes.get(operation.configId); + if (config === undefined) continue; + const target = bindingOrOwner( + context, + operation.resultBindingId, + operation.ownerCallableId, + ); + addSemanticGraphRelation(context.state, { + source: config, + target, + relation: configurationRelation(operation), + resolution: + operation.resolution === "complete" ? "resolved" : "candidate", + }); + if (operation.kind === "default" && operation.sourceConfigId !== null) + addSemanticGraphRelation(context.state, { + source: configNodes.get(operation.sourceConfigId), + target: config, + relation: "overrides", + resolution: + operation.resolution === "complete" ? "resolved" : "candidate", + }); + if (operation.resolution !== "complete") + addDataUnknown({ + context, + node: config, + family: "configuration", + relationKinds: [ + configurationRelation(operation), + ...(operation.kind === "default" ? (["overrides"] as const) : []), + ], + location: operation.location, + detail: `Static ${operation.kind} configuration identity is partial.`, + candidateNodeIds: [], + }); + } +}; + +const addConfigurationNode = ( + context: SemanticFlowProjectionContext, + operation: JavaScriptSemanticConfigurationOperation, +): JavaScriptSemanticGraphNode | null => + addSemanticGraphNode( + context.state, + constructSemanticGraphNode( + context.file, + { + kind: "config-source", + roleKey: operation.configId, + location: operation.location, + label: operation.key ?? operation.kind, + functionNodeId: + operation.ownerCallableId === null + ? null + : (context.callableNodes.get(operation.ownerCallableId)?.node_id ?? + null), + properties: { + key: operation.key, + source_kind: operation.kind, + value: operation.value, + }, + }, + context.state, + ), + ); + +const configurationRelation = ( + operation: JavaScriptSemanticConfigurationOperation, +): "reads-environment" | "reads-argv" | "reads-config" | "defaults" => + operation.kind === "environment" + ? "reads-environment" + : operation.kind === "argv" + ? "reads-argv" + : operation.kind === "file" + ? "reads-config" + : "defaults"; + +/** Project request construction, field provenance, and response consumers. */ +export const projectSemanticRequests = ( + context: SemanticFlowProjectionContext, +): void => { + const requestNodes = new Map( + context.ir.requestOperations.flatMap((operation) => { + const node = addRequestNode(context, operation); + return node === null ? [] : [[operation.requestId, node] as const]; + }), + ); + for (const operation of context.ir.requestOperations) { + const request = requestNodes.get(operation.requestId); + if (request === undefined) continue; + if (operation.kind === "request") + projectRequestConstruction(context, operation, request); + else projectResponseConsumer(context, operation, request); + if (operation.resolution !== "complete") + addDataUnknown({ + context, + node: request, + family: "request", + relationKinds: + operation.kind === "request" + ? ["constructs-request", "supplies-request-field"] + : ["consumed-by"], + location: operation.location, + detail: `Static ${operation.method} request resolution was ${operation.resolution}.`, + candidateNodeIds: operation.linkedRequestIds.flatMap((identifier) => { + const candidate = requestNodes.get(identifier); + return candidate === undefined ? [] : [candidate.node_id]; + }), + }); + } +}; + +const addRequestNode = ( + context: SemanticFlowProjectionContext, + operation: JavaScriptSemanticRequestOperation, +): JavaScriptSemanticGraphNode | null => + addSemanticGraphNode( + context.state, + constructSemanticGraphNode( + context.file, + { + kind: operation.kind === "request" ? "request" : "response", + roleKey: operation.requestId, + location: operation.location, + label: operation.endpoint ?? operation.method, + functionNodeId: + operation.ownerCallableId === null + ? null + : (context.callableNodes.get(operation.ownerCallableId)?.node_id ?? + null), + properties: { + endpoint: operation.endpoint, + method: operation.method, + operation_kind: operation.kind, + }, + }, + context.state, + ), + ); + +const projectRequestConstruction = ( + context: SemanticFlowProjectionContext, + operation: JavaScriptSemanticRequestOperation, + request: JavaScriptSemanticGraphNode, +): void => { + const callSite = semanticCallSiteAt(context, operation.location); + addSemanticGraphRelation(context.state, { + source: callSite, + target: request, + relation: "constructs-request", + resolution: operation.resolution === "complete" ? "resolved" : "candidate", + }); + for (const field of operation.fields) + addSemanticGraphRelation(context.state, { + source: + field.sourceBindingId === null + ? callSite + : context.bindingNodes.get(field.sourceBindingId), + target: request, + relation: "supplies-request-field", + resolution: field.sourceBindingId === null ? "candidate" : "resolved", + properties: { field: field.name }, + }); +}; + +const projectResponseConsumer = ( + context: SemanticFlowProjectionContext, + operation: JavaScriptSemanticRequestOperation, + response: JavaScriptSemanticGraphNode, +): void => { + addSemanticGraphRelation(context.state, { + source: response, + target: semanticCallSiteAt(context, operation.location), + relation: "consumed-by", + resolution: operation.resolution === "complete" ? "resolved" : "candidate", + properties: { method: operation.method }, + }); +}; + +/** Project parse, coercion, and validation consumers as static boundaries. */ +export const projectSemanticBoundaries = ( + context: SemanticFlowProjectionContext, +): void => { + for (const operation of context.ir.boundaryOperations) { + const boundary = addBoundaryNode(context, operation); + if (boundary === null) continue; + addSemanticGraphRelation(context.state, { + source: bindingOrOwner( + context, + operation.sourceBindingId, + operation.ownerCallableId, + ), + target: boundary, + relation: + operation.kind === "parse" + ? "parses" + : operation.kind === "coerce" + ? "coerces" + : "validates", + resolution: + operation.resolution === "complete" ? "resolved" : "candidate", + }); + if (operation.resolution !== "complete") + addDataUnknown({ + context, + node: boundary, + family: "boundary", + relationKinds: [ + operation.kind === "parse" + ? "parses" + : operation.kind === "coerce" + ? "coerces" + : "validates", + ], + location: operation.location, + detail: `Static ${operation.method} boundary kind is a candidate.`, + candidateNodeIds: [], + }); + } +}; + +const addBoundaryNode = ( + context: SemanticFlowProjectionContext, + operation: JavaScriptSemanticBoundaryOperation, +): JavaScriptSemanticGraphNode | null => + addSemanticGraphNode( + context.state, + constructSemanticGraphNode( + context.file, + { + kind: "boundary", + roleKey: operation.boundaryId, + location: operation.location, + label: operation.method, + functionNodeId: + operation.ownerCallableId === null + ? null + : (context.callableNodes.get(operation.ownerCallableId)?.node_id ?? + null), + properties: { + boundary_kind: operation.kind, + method: operation.method, + }, + }, + context.state, + ), + ); + +const bindingOrOwner = ( + context: SemanticFlowProjectionContext, + bindingId: string | null, + callableId: string | null, +): JavaScriptSemanticGraphNode => + (bindingId === null ? undefined : context.bindingNodes.get(bindingId)) ?? + (callableId === null + ? context.moduleNode + : (context.callableNodes.get(callableId) ?? context.moduleNode)); + +interface DataUnknownInput { + readonly context: SemanticFlowProjectionContext; + readonly node: JavaScriptSemanticGraphNode; + readonly family: "boundary" | "configuration" | "request"; + readonly relationKinds: readonly ( + | "coerces" + | "constructs-request" + | "consumed-by" + | "defaults" + | "overrides" + | "parses" + | "reads-argv" + | "reads-config" + | "reads-environment" + | "supplies-request-field" + | "validates" + )[]; + readonly location: JavaScriptSemanticBoundaryOperation["location"]; + readonly detail: string; + readonly candidateNodeIds: readonly string[]; +} + +const addDataUnknown = (input: DataUnknownInput): void => { + const { + context, + node, + family, + relationKinds, + location, + detail, + candidateNodeIds, + } = input; + addSemanticGraphUnknown( + context.state, + createJavaScriptSemanticGraphUnknown({ + node_id: node.node_id, + family, + relation_kinds: relationKinds, + reason: "ambiguous-target", + detail, + candidate_node_ids: candidateNodeIds, + evidence: unknownSemanticEvidence(context.file, location), + }), + ); +}; diff --git a/src/application/JavaScriptSemanticGraphFingerprintProjection.ts b/src/application/JavaScriptSemanticGraphFingerprintProjection.ts new file mode 100644 index 000000000..8521806a1 --- /dev/null +++ b/src/application/JavaScriptSemanticGraphFingerprintProjection.ts @@ -0,0 +1,39 @@ +import { + createJavaScriptSemanticFingerprint, + type JavaScriptSemanticGraph, + type JavaScriptSemanticGraphNode, +} from "../domain/javascriptSemanticGraph.js"; +import type { JavaScriptSemanticIr } from "../domain/javascriptSemanticIr.js"; +import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js"; +import { observedSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js"; + +/** Bind domain fingerprint components to exact semantic function nodes. */ +export const projectSemanticFunctionFingerprints = ( + file: JavaScriptArtifactFile, + ir: JavaScriptSemanticIr, + callableNodes: ReadonlyMap, +): JavaScriptSemanticGraph["fingerprints"][number][] => + ir.functionFingerprints.flatMap((fingerprint) => { + const callable = callableNodes.get(fingerprint.callableId); + if (callable === undefined) return []; + return [ + createJavaScriptSemanticFingerprint({ + function_node_id: callable.node_id, + algorithm: "rea.javascript-semantic-function/v1", + status: fingerprint.status, + components: { + parameter_arity: fingerprint.components.parameterArity, + normalized_ast_sha256: fingerprint.components.normalizedAstSha256, + control_flow_sha256: fingerprint.components.controlFlowSha256, + relation_shape_sha256: fingerprint.components.relationShapeSha256, + literal_set_sha256: fingerprint.components.literalSetSha256, + effects: fingerprint.components.effects, + }, + limitations: fingerprint.limitations, + evidence: observedSemanticEvidence( + file, + callable.identity.source_range, + ), + }), + ]; + }); diff --git a/src/application/JavaScriptSemanticGraphFlowProjection.ts b/src/application/JavaScriptSemanticGraphFlowProjection.ts index 292deecd9..0cb83605b 100644 --- a/src/application/JavaScriptSemanticGraphFlowProjection.ts +++ b/src/application/JavaScriptSemanticGraphFlowProjection.ts @@ -1,11 +1,15 @@ import { createJavaScriptSemanticGraphUnknown } from "../domain/javascriptSemanticGraph.js"; import type { JavaScriptSemanticGraphNode } from "../domain/javascriptSemanticGraph.js"; import type { JavaScriptSemanticIr } from "../domain/javascriptSemanticIr.js"; +import { sourceRangesEqual } from "../domain/javascriptStaticAnalysisHelpers.js"; +import type { JavaScriptSourceRange } from "../domain/javascriptStaticAnalysisTypes.js"; import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js"; import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js"; import { + addSemanticGraphNode, addSemanticGraphRelation, addSemanticGraphUnknown, + constructSemanticGraphNode, type SemanticGraphProjectionState, } from "./JavaScriptSemanticGraphConstruction.js"; import { @@ -22,10 +26,191 @@ export interface SemanticFlowProjectionContext { readonly moduleNode: JavaScriptSemanticGraphNode; readonly bindingNodes: ReadonlyMap; readonly callableNodes: ReadonlyMap; + readonly callSiteNodes: ReadonlyMap; readonly returnSiteNodes: ReadonlyMap; readonly referenceNodes: readonly JavaScriptSemanticGraphNode[]; } +/** Find the retained call-site node at one exact semantic source range. */ +export const semanticCallSiteAt = ( + context: SemanticFlowProjectionContext, + location: JavaScriptSourceRange, +): JavaScriptSemanticGraphNode | undefined => { + const call = context.ir.callSites.find(({ location: candidate }) => + sourceRangesEqual(candidate, location), + ); + return call === undefined + ? undefined + : context.callSiteNodes.get(call.callSiteId); +}; + +/** Project explicit static Promise/task ownership without runtime claims. */ +export const projectSemanticPromises = ( + context: SemanticFlowProjectionContext, +): void => { + const promiseNodes = new Map( + context.ir.promiseOperations.flatMap((operation) => { + const owner = + operation.ownerCallableId === null + ? undefined + : context.callableNodes.get(operation.ownerCallableId); + const node = addSemanticGraphNode( + context.state, + constructSemanticGraphNode( + context.file, + { + kind: operation.kind === "awaited-expression" ? "task" : "promise", + roleKey: operation.promiseId, + location: operation.location, + label: promiseLabel(operation), + functionNodeId: owner?.node_id ?? null, + properties: { + method: operation.method, + operation_kind: operation.kind, + ownership: operation.ownership, + source_resolution: operation.sourceResolution, + }, + }, + context.state, + ), + ); + return node === null ? [] : [[operation.promiseId, node] as const]; + }), + ); + for (const operation of context.ir.promiseOperations) { + const promise = promiseNodes.get(operation.promiseId); + if (promise === undefined) continue; + projectPromiseCreation(context, operation, promise); + projectPromiseSources(context, operation, promise, promiseNodes); + projectPromiseOwnership(context, operation, promise); + if ( + operation.sourceResolution !== "complete" && + ["chain", "aggregate", "awaited-expression"].includes(operation.kind) + ) + addPromiseUnknown(context, operation, promise, promiseNodes); + } +}; + +const promiseLabel = ( + operation: JavaScriptSemanticIr["promiseOperations"][number], +): string => + operation.kind === "awaited-expression" + ? "awaited expression" + : `Promise.${operation.method}`; + +const projectPromiseCreation = ( + context: SemanticFlowProjectionContext, + operation: JavaScriptSemanticIr["promiseOperations"][number], + promise: JavaScriptSemanticGraphNode, +): void => { + if (operation.kind === "awaited-expression") return; + addSemanticGraphRelation(context.state, { + source: semanticCallSiteAt(context, operation.location), + target: promise, + relation: "creates-promise", + resolution: + operation.kind === "chain" || operation.sourceResolution !== "complete" + ? "candidate" + : "resolved", + }); +}; + +const projectPromiseSources = ( + context: SemanticFlowProjectionContext, + operation: JavaScriptSemanticIr["promiseOperations"][number], + promise: JavaScriptSemanticGraphNode, + promiseNodes: ReadonlyMap, +): void => { + for (const sourceId of operation.sourcePromiseIds) { + const source = promiseNodes.get(sourceId); + if (operation.kind === "aggregate") + addSemanticGraphRelation(context.state, { + source: promise, + target: source, + relation: "aggregates", + resolution: + operation.sourceResolution === "complete" ? "resolved" : "candidate", + }); + else + addSemanticGraphRelation(context.state, { + source, + target: promise, + relation: "chains", + resolution: + operation.sourceResolution === "complete" ? "resolved" : "candidate", + }); + } +}; + +const projectPromiseOwnership = ( + context: SemanticFlowProjectionContext, + operation: JavaScriptSemanticIr["promiseOperations"][number], + promise: JavaScriptSemanticGraphNode, +): void => { + const owner = + operation.ownerCallableId === null + ? context.moduleNode + : context.callableNodes.get(operation.ownerCallableId); + if (operation.ownership === "awaited") + addSemanticGraphRelation(context.state, { + source: owner, + target: promise, + relation: "awaits", + resolution: "resolved", + }); + else if (operation.ownership === "assigned") + addSemanticGraphRelation(context.state, { + source: + operation.ownerBindingId === null + ? undefined + : context.bindingNodes.get(operation.ownerBindingId), + target: promise, + relation: "owns", + resolution: "resolved", + }); + else if (operation.ownership === "returned") + addSemanticGraphRelation(context.state, { + source: + operation.returnSiteId === null + ? undefined + : context.returnSiteNodes.get(operation.returnSiteId), + target: promise, + relation: "returns-task", + resolution: "resolved", + }); + else if (operation.ownership === "detached") + addSemanticGraphRelation(context.state, { + source: owner, + target: promise, + relation: "detaches-task", + resolution: "resolved", + }); +}; + +const addPromiseUnknown = ( + context: SemanticFlowProjectionContext, + operation: JavaScriptSemanticIr["promiseOperations"][number], + promise: JavaScriptSemanticGraphNode, + promiseNodes: ReadonlyMap, +): void => { + const relation = operation.kind === "aggregate" ? "aggregates" : "chains"; + addSemanticGraphUnknown( + context.state, + createJavaScriptSemanticGraphUnknown({ + node_id: promise.node_id, + family: "promise-ownership", + relation_kinds: [relation], + reason: "ambiguous-target", + detail: `Static ${operation.method} source resolution was ${operation.sourceResolution}.`, + candidate_node_ids: operation.sourcePromiseIds.flatMap((identifier) => { + const candidate = promiseNodes.get(identifier); + return candidate === undefined ? [] : [candidate.node_id]; + }), + evidence: unknownSemanticEvidence(context.file, operation.location), + }), + ); +}; + /** Link direct return expressions to their retained semantic references. */ export const projectSemanticReturnValues = ( context: SemanticFlowProjectionContext, @@ -44,7 +229,7 @@ export const projectSemanticReturnValues = ( relation: "aliases", resolution: site.identityReferenceLocation !== null && - rangesEqual( + sourceRangesEqual( reference.identity.source_range, site.identityReferenceLocation, ) @@ -54,17 +239,6 @@ export const projectSemanticReturnValues = ( } }; -const rangesEqual = ( - left: JavaScriptSemanticGraphNode["identity"]["source_range"], - right: JavaScriptSemanticGraphNode["identity"]["source_range"], -): boolean => - left !== null && - right !== null && - left.start.line === right.start.line && - left.start.column === right.start.column && - left.end.line === right.end.line && - left.end.column === right.end.column; - /** Link each captured binding to the callable at the exact reference site. */ export const projectSemanticClosureCaptures = ( context: SemanticFlowProjectionContext, diff --git a/src/application/JavaScriptSemanticGraphObjectProjection.ts b/src/application/JavaScriptSemanticGraphObjectProjection.ts new file mode 100644 index 000000000..73baf0670 --- /dev/null +++ b/src/application/JavaScriptSemanticGraphObjectProjection.ts @@ -0,0 +1,126 @@ +import { createJavaScriptSemanticGraphUnknown } from "../domain/javascriptSemanticGraph.js"; +import type { JavaScriptSemanticGraphNode } from "../domain/javascriptSemanticGraph.js"; +import type { JavaScriptSemanticObjectOperation } from "../domain/javascriptSemanticIr.js"; +import { + addSemanticGraphNode, + addSemanticGraphRelation, + addSemanticGraphUnknown, + constructSemanticGraphNode, +} from "./JavaScriptSemanticGraphConstruction.js"; +import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js"; +import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlowProjection.js"; +import { semanticPropertySlot } from "./JavaScriptSemanticGraphValueProjection.js"; + +/** Project static object reads, writes, spreads, and destructuring. */ +export const projectSemanticObjects = ( + context: SemanticFlowProjectionContext, +): void => { + for (const operation of context.ir.objectOperations) { + const occurrence = addObjectOccurrence(context, operation); + const slot = + operation.objectBindingId === null || operation.propertyName === null + ? null + : semanticPropertySlot( + context, + operation.objectBindingId, + operation.propertyName, + ); + if (operation.kind === "read") + addSemanticGraphRelation(context.state, { + source: slot, + target: occurrence, + relation: "reads-property", + resolution: + operation.resolution === "complete" ? "resolved" : "candidate", + }); + else if (operation.kind === "write") + addSemanticGraphRelation(context.state, { + source: occurrence, + target: slot, + relation: "writes-property", + resolution: + operation.resolution === "complete" ? "resolved" : "candidate", + }); + else if (operation.kind === "destructure") + addSemanticGraphRelation(context.state, { + source: slot, + target: + operation.targetBindingId === null + ? undefined + : context.bindingNodes.get(operation.targetBindingId), + relation: "destructures", + resolution: + operation.resolution === "complete" ? "resolved" : "candidate", + }); + else + addSemanticGraphRelation(context.state, { + source: + operation.objectBindingId === null + ? undefined + : context.bindingNodes.get(operation.objectBindingId), + target: occurrence, + relation: "spreads", + resolution: + operation.resolution === "complete" ? "resolved" : "candidate", + }); + if (operation.resolution !== "complete") + addObjectUnknown(context, operation, slot ?? occurrence); + } +}; + +const addObjectOccurrence = ( + context: SemanticFlowProjectionContext, + operation: JavaScriptSemanticObjectOperation, +): JavaScriptSemanticGraphNode | null => + addSemanticGraphNode( + context.state, + constructSemanticGraphNode( + context.file, + { + kind: "expression", + roleKey: operation.objectOperationId, + location: operation.location, + label: + operation.propertyName === null + ? operation.kind + : `${operation.kind}:${operation.propertyName}`, + functionNodeId: + operation.ownerCallableId === null + ? null + : (context.callableNodes.get(operation.ownerCallableId)?.node_id ?? + null), + properties: { + operation_kind: operation.kind, + property_name: operation.propertyName, + }, + }, + context.state, + ), + ); + +const addObjectUnknown = ( + context: SemanticFlowProjectionContext, + operation: JavaScriptSemanticObjectOperation, + node: JavaScriptSemanticGraphNode | null, +): void => { + const relation = + operation.kind === "read" + ? "reads-property" + : operation.kind === "write" + ? "writes-property" + : operation.kind === "spread" + ? "spreads" + : "destructures"; + addSemanticGraphUnknown( + context.state, + createJavaScriptSemanticGraphUnknown({ + node_id: node?.node_id ?? null, + family: "object-flow", + relation_kinds: [relation], + reason: "ambiguous-target", + detail: `Static ${operation.kind} object identity is partial.`, + candidate_node_ids: [], + evidence: unknownSemanticEvidence(context.file, operation.location), + }), + ); +}; diff --git a/src/application/JavaScriptSemanticGraphProjection.ts b/src/application/JavaScriptSemanticGraphProjection.ts index 5de02a28d..7a67f2050 100644 --- a/src/application/JavaScriptSemanticGraphProjection.ts +++ b/src/application/JavaScriptSemanticGraphProjection.ts @@ -41,23 +41,27 @@ export const semanticFamilyStatus = ( analysis: JavaScriptArtifactAnalysis, truncated: boolean, ): "complete" | "partial" | "unknown" | "unsupported" => { - if (!["call-flow", "closure", "data-flow", "object-flow"].includes(family)) + if ( + ![ + "call-flow", + "boundary", + "child-process", + "closure", + "configuration", + "data-flow", + "event", + "object-flow", + "promise-ownership", + "request", + "resource-lifecycle", + "timer", + ].includes(family) + ) return "unsupported"; if (truncated) return "unknown"; return analysis.truncated_scopes === 0 ? "partial" : "unknown"; }; -/** Compare two exact semantic source ranges. */ -export const semanticRangesEqual = ( - left: JavaScriptSourceRange | null, - right: JavaScriptSourceRange, -): boolean => - left !== null && - left.start.line === right.start.line && - left.start.column === right.start.column && - left.end.line === right.end.line && - left.end.column === right.end.column; - const contains = ( outer: JavaScriptSourceRange, inner: JavaScriptSourceRange, diff --git a/src/application/JavaScriptSemanticGraphResourceProjection.ts b/src/application/JavaScriptSemanticGraphResourceProjection.ts new file mode 100644 index 000000000..1483b9905 --- /dev/null +++ b/src/application/JavaScriptSemanticGraphResourceProjection.ts @@ -0,0 +1,95 @@ +import { createJavaScriptSemanticGraphUnknown } from "../domain/javascriptSemanticGraph.js"; +import type { JavaScriptSemanticGraphNode } from "../domain/javascriptSemanticGraph.js"; +import type { JavaScriptSemanticResourceOperation } from "../domain/javascriptSemanticIr.js"; +import { + addSemanticGraphNode, + addSemanticGraphRelation, + addSemanticGraphUnknown, + constructSemanticGraphNode, +} from "./JavaScriptSemanticGraphConstruction.js"; +import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js"; +import { + semanticCallSiteAt, + type SemanticFlowProjectionContext, +} from "./JavaScriptSemanticGraphFlowProjection.js"; + +/** Project built-in resource acquisition and exact local release handles. */ +export const projectSemanticResources = ( + context: SemanticFlowProjectionContext, +): void => { + const resourceNodes = new Map( + context.ir.resourceOperations.flatMap((operation) => { + if (operation.kind !== "acquire") return []; + const node = addResourceNode(context, operation); + return node === null ? [] : [[operation.resourceId, node] as const]; + }), + ); + for (const operation of context.ir.resourceOperations) + if (operation.kind === "acquire") + addSemanticGraphRelation(context.state, { + source: semanticCallSiteAt(context, operation.location), + target: resourceNodes.get(operation.resourceId), + relation: "acquires", + resolution: "resolved", + }); + else projectRelease(context, operation, resourceNodes); +}; + +const addResourceNode = ( + context: SemanticFlowProjectionContext, + operation: JavaScriptSemanticResourceOperation, +): JavaScriptSemanticGraphNode | null => + addSemanticGraphNode( + context.state, + constructSemanticGraphNode( + context.file, + { + kind: "resource", + roleKey: operation.resourceId, + location: operation.location, + label: operation.method, + functionNodeId: + operation.ownerCallableId === null + ? null + : (context.callableNodes.get(operation.ownerCallableId)?.node_id ?? + null), + properties: { method: operation.method }, + }, + context.state, + ), + ); + +const projectRelease = ( + context: SemanticFlowProjectionContext, + operation: JavaScriptSemanticResourceOperation, + resourceNodes: ReadonlyMap, +): void => { + const callSite = semanticCallSiteAt(context, operation.location); + const resources = operation.linkedResourceIds.flatMap((identifier) => { + const resource = resourceNodes.get(identifier); + return resource === undefined ? [] : [resource]; + }); + for (const resource of resources) + addSemanticGraphRelation(context.state, { + source: callSite, + target: resource, + relation: "releases", + resolution: + operation.resolution === "complete" ? "resolved" : "candidate", + }); + if (operation.resolution === "complete") return; + addSemanticGraphUnknown( + context.state, + createJavaScriptSemanticGraphUnknown({ + node_id: callSite?.node_id ?? null, + family: "resource-lifecycle", + relation_kinds: ["releases"], + reason: "ambiguous-target", + detail: `Static ${operation.method} resource ownership is ambiguous.`, + candidate_node_ids: resources.map( + ({ node_id: identifier }) => identifier, + ), + evidence: unknownSemanticEvidence(context.file, operation.location), + }), + ); +}; diff --git a/src/application/JavaScriptSemanticGraphValueProjection.ts b/src/application/JavaScriptSemanticGraphValueProjection.ts new file mode 100644 index 000000000..4b61dcd4a --- /dev/null +++ b/src/application/JavaScriptSemanticGraphValueProjection.ts @@ -0,0 +1,129 @@ +import type { JsonValue } from "../domain/jsonValue.js"; +import type { + JavaScriptSemanticBinding, + JavaScriptSemanticValue, +} from "../domain/javascriptSemanticIr.js"; +import { + addSemanticGraphNode, + addSemanticGraphRelation, + constructSemanticGraphNode, +} from "./JavaScriptSemanticGraphConstruction.js"; +import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlowProjection.js"; + +/** Project bounded literal values and object slots for exact query seeds. */ +export const projectSemanticValues = ( + context: SemanticFlowProjectionContext, +): void => { + for (const binding of context.ir.bindings) { + const bindingNode = context.bindingNodes.get(binding.bindingId); + if (bindingNode === undefined) continue; + projectValue({ + context, + binding, + value: binding.value, + target: bindingNode, + role: "binding", + }); + } +}; + +interface ValueProjectionInput { + readonly context: SemanticFlowProjectionContext; + readonly binding: JavaScriptSemanticBinding; + readonly value: JavaScriptSemanticValue; + readonly target: ReturnType; + readonly role: string; +} + +const projectValue = (input: ValueProjectionInput): void => { + const { context, binding, value, target, role } = input; + if (target === null) return; + if (value.status === "literal") { + const literal = addLiteralNode(context, binding, value.value, role); + addSemanticGraphRelation(context.state, { + source: literal, + target, + relation: "defines", + resolution: "resolved", + }); + } else if (value.status === "union") + for (const primitive of value.values) { + const literal = addLiteralNode(context, binding, primitive, role); + addSemanticGraphRelation(context.state, { + source: literal, + target, + relation: "defines", + resolution: "candidate", + }); + } + else if (value.status === "object") + for (const property of value.properties) { + const slot = semanticPropertySlot( + context, + binding.bindingId, + property.name, + ); + addSemanticGraphRelation(context.state, { + source: target, + target: slot, + relation: "writes-property", + resolution: value.unknownProperties ? "candidate" : "resolved", + }); + projectValue({ + context, + binding, + value: property.value, + target: slot, + role: `property:${property.name}`, + }); + } +}; + +const addLiteralNode = ( + context: SemanticFlowProjectionContext, + binding: JavaScriptSemanticBinding, + value: string | number | boolean | null, + role: string, +) => + addSemanticGraphNode( + context.state, + constructSemanticGraphNode( + context.file, + { + kind: "literal", + roleKey: `literal:${binding.bindingId}:${role}:${JSON.stringify(value)}`, + location: binding.definitions[0]?.location ?? null, + label: JSON.stringify(value), + functionNodeId: + context.bindingNodes.get(binding.bindingId)?.function_node_id ?? null, + properties: { value }, + }, + context.state, + ), + ); + +/** Create one canonical property slot for a binding/property identity. */ +export const semanticPropertySlot = ( + context: SemanticFlowProjectionContext, + objectBindingId: string, + name: string, +) => + addSemanticGraphNode( + context.state, + constructSemanticGraphNode( + context.file, + { + kind: "property-slot", + roleKey: `property:${objectBindingId}:${name}`, + location: null, + label: name, + functionNodeId: + context.bindingNodes.get(objectBindingId)?.function_node_id ?? null, + properties: { + name, + object_binding_id: objectBindingId, + } satisfies Readonly>, + }, + context.state, + ), + ); diff --git a/src/application/ProcessReactiveEffects.ts b/src/application/ProcessReactiveEffects.ts index efb0a8d6c..4153a9be5 100644 --- a/src/application/ProcessReactiveEffects.ts +++ b/src/application/ProcessReactiveEffects.ts @@ -14,6 +14,7 @@ import type { ProcessCaptureJournal } from "./ProcessCaptureJournal.js"; export interface ProcessReactiveTerminal { write(data: string): void; resize(columns: number, rows: number): void; + closeInput(): void; kill(signal: "SIGINT" | "SIGTERM" | "SIGKILL"): void; } @@ -56,6 +57,7 @@ const interactionData = ( : action.data; if (action.type === "resize") return `${String(action.columns)}x${String(action.rows)}`; + if (action.type === "close_stdin") return ""; return action.signal; }; @@ -64,6 +66,7 @@ const interactionType = ( ): InteractionEvent["type"] => { if (action.type === "send_input") return "input"; if (action.type === "resize") return "resize"; + if (action.type === "close_stdin") return "stdin_close"; return "signal"; }; @@ -77,7 +80,8 @@ const performTerminalAction = ( else if (action.type === "resize") { terminal.resize(action.columns, action.rows); renderer.resize(action.columns, action.rows, atMs); - } else terminal.kill(action.signal); + } else if (action.type === "close_stdin") terminal.closeInput(); + else terminal.kill(action.signal); }; const recordInteraction = ( diff --git a/src/application/ProcessReactiveHarness.ts b/src/application/ProcessReactiveHarness.ts index 619b7104d..910378d3e 100644 --- a/src/application/ProcessReactiveHarness.ts +++ b/src/application/ProcessReactiveHarness.ts @@ -17,6 +17,7 @@ import type { CommandShimReplay } from "./CommandShimReplay.js"; import { ProcessReactiveCoordinator } from "./ProcessReactiveCoordinator.js"; import { executeProcessReactiveEffects, + type ProcessReactiveTerminal, unsupportedProcessReactiveFeatures, } from "./ProcessReactiveEffects.js"; import { subscribeProcessReactiveObservations } from "./ProcessReactiveObservations.js"; @@ -34,6 +35,25 @@ export interface ProcessReactiveHarness { readonly unsubscribe: () => void; } +const reactiveTerminalFor = ( + terminal: IPty | undefined, +): ProcessReactiveTerminal | undefined => { + if (terminal === undefined) return undefined; + return { + write: (data) => terminal.write(data), + resize: (columns, rows) => terminal.resize(columns, rows), + closeInput: () => { + const end: unknown = Reflect.get(terminal, "end"); + if (typeof end !== "function") + throw new ProcessCaptureError( + "PTY backend does not expose an stdin-close operation", + ); + Reflect.apply(end, terminal, []); + }, + kill: (signal) => terminal.kill(signal), + }; +}; + const controlKind = ( input: ProcessReactiveInput, ): ProcessReactiveControlRecord["kind"] | undefined => @@ -81,7 +101,7 @@ export const startProcessReactiveHarness = (options: { execute: (actions, signal) => executeProcessReactiveEffects( { - terminal: options.terminal, + terminal: () => reactiveTerminalFor(options.terminal()), renderer: options.renderer, checkpoints: options.checkpoints, interactions: options.capture.interactions, diff --git a/src/application/ProjectPermissionStore.ts b/src/application/ProjectPermissionStore.ts index 55d1c10a9..0c95d1796 100644 --- a/src/application/ProjectPermissionStore.ts +++ b/src/application/ProjectPermissionStore.ts @@ -23,6 +23,7 @@ const grantSchema = z.object({ capability: z.enum([ "process_capture", "browser_observe", + "browser_automate", "electron_observe", "evidence_read", "evidence_write", diff --git a/src/application/ReconstructionObligationAuthority.ts b/src/application/ReconstructionObligationAuthority.ts new file mode 100644 index 000000000..cf2813d9b --- /dev/null +++ b/src/application/ReconstructionObligationAuthority.ts @@ -0,0 +1,109 @@ +import type { Evidence } from "../domain/evidence.js"; +import type { ReconstructionObligation } from "../domain/reconstructionObligationLedgerSchemas.js"; + +type ProofAuthority = ReconstructionObligation["required_fixture_authority"]; +type OriginalAuthority = + ReconstructionObligation["required_original_authority"]; +interface ExpectedProof { + readonly obligationId: string; + readonly fixtureId?: string; + readonly caseKind?: string; + readonly verifierId?: string; + readonly claimId?: string; +} + +const proofEvidenceAuthorities: Readonly< + Record +> = { + unit: ["shipped-artifact", "controlled-replay"], + integration: ["controlled-replay"], + protocol: ["controlled-replay", "external-service"], + renderer: ["controlled-replay", "external-service"], + "packaged-process": ["controlled-replay"], + "native-abi": ["shipped-artifact", "controlled-replay"], + "live-observation": ["controlled-replay", "external-service"], + external: ["external-service"], +}; + +/** Return whether an actual reconstruction proof is comparable to a requirement. */ +export const proofAuthoritySatisfies = ( + actual: ProofAuthority, + required: ProofAuthority, +): boolean => { + const accepted: Readonly> = + { + unit: [ + "unit", + "integration", + "protocol", + "renderer", + "packaged-process", + "native-abi", + "live-observation", + "external", + ], + integration: [ + "integration", + "protocol", + "renderer", + "packaged-process", + "native-abi", + "live-observation", + ], + protocol: ["protocol", "packaged-process", "live-observation"], + renderer: ["renderer", "packaged-process", "live-observation"], + "packaged-process": ["packaged-process", "live-observation"], + "native-abi": ["native-abi"], + "live-observation": ["live-observation"], + external: ["external"], + }; + return accepted[required].includes(actual); +}; + +/** Check that Evidence can authenticate the declared reconstruction authority. */ +export const evidenceAuthoritySupportsProof = ( + evidence: Evidence, + authority: ProofAuthority, + expected: ExpectedProof, +): boolean => { + if ( + evidence.confidence !== "observed" || + !proofEvidenceAuthorities[authority].includes(evidence.authority) || + evidence.predicate_type !== "rea.reconstruction-proof/v1" || + evidence.operation !== "verify_reconstruction_obligations" + ) + return false; + const result = evidence.normalized_result; + if (typeof result !== "object" || result === null || Array.isArray(result)) + return false; + const includes = (key: string, value: string | undefined): boolean => + value === undefined || + (Array.isArray(result[key]) && result[key].includes(value)); + return ( + result.passed === true && + includes("obligation_ids", expected.obligationId) && + includes("fixture_ids", expected.fixtureId) && + includes("case_kinds", expected.caseKind) && + includes("verifier_ids", expected.verifierId) && + includes("claim_ids", expected.claimId) + ); +}; + +/** Check that Evidence observes the original at the required boundary. */ +export const evidenceSupportsOriginalAuthority = ( + evidence: Evidence, + authority: OriginalAuthority, +): boolean => { + if (evidence.confidence !== "observed") return false; + if (authority === "static") return evidence.authority === "shipped-artifact"; + if (authority === "runtime") + return ["controlled-replay", "external-service"].includes( + evidence.authority, + ); + if (authority === "process") + return ( + evidence.operation === "capture_process_scenario" && + evidence.predicate_type === "rea.process-capture/v4" + ); + return evidence.authority === "external-service"; +}; diff --git a/src/application/ReconstructionObligationCandidates.ts b/src/application/ReconstructionObligationCandidates.ts new file mode 100644 index 000000000..b83100d65 --- /dev/null +++ b/src/application/ReconstructionObligationCandidates.ts @@ -0,0 +1,416 @@ +import { createHash } from "node:crypto"; + +import canonicalize from "canonicalize"; + +import type { Evidence } from "../domain/evidence.js"; +import { + parseEvidenceBundle, + type EvidenceBundle, +} from "../domain/evidenceBundle.js"; +import { parseProcessCapture } from "../domain/processCapture.js"; +import type { + ReconstructionObligation, + ReviewedReconstructionObligation, +} from "../domain/reconstructionObligationLedgerSchemas.js"; +import { parseApplicationGraphEvidence } from "./JavaScriptApplicationEvidenceGraph.js"; +import { + applicationObligationPolicy, + semanticObligationPolicy, + type ReconstructionObligationCandidatePolicy, +} from "./ReconstructionObligationPolicies.js"; + +type CandidateFields = Omit< + ReconstructionObligation, + "binding" | "status" | "diagnostics" +>; + +/** Internal generated or reviewed obligation before manifest closure evaluation. */ +export interface ReconstructionObligationCandidate extends CandidateFields { + readonly definitionCount: number; + readonly disposition: "active" | "blocked" | "out-of-scope"; +} + +export interface ReconstructionObligationCandidateSet { + readonly candidates: readonly ReconstructionObligationCandidate[]; + readonly limitations: readonly string[]; +} + +/** Derive conservative candidates from graph, process, and reviewed inputs. */ +export const deriveReconstructionObligationCandidates = ( + bundleInput: EvidenceBundle, + reviewed: readonly ReviewedReconstructionObligation[], +): ReconstructionObligationCandidateSet => { + const bundle = parseEvidenceBundle(bundleInput); + const records = new Map( + bundle.records.map((record) => [record.evidence_id, record]), + ); + const candidates = new Map(); + const limitations = new Set(); + for (const evidence of bundle.records) { + deriveApplicationCandidates(evidence, records, candidates, limitations); + deriveProcessCandidate(evidence, candidates, limitations); + } + for (const obligation of reviewed) + addCandidate(candidates, reviewedCandidate(obligation, records)); + return { + candidates: [...candidates.values()].sort((left, right) => + left.obligation_id.localeCompare(right.obligation_id), + ), + limitations: [...limitations].sort(), + }; +}; + +const deriveApplicationCandidates = ( + evidence: Evidence, + records: ReadonlyMap, + candidates: Map, + limitations: Set, +): void => { + if ( + ![ + "analyze_javascript_application", + "reconcile_javascript_runtime", + "project_managed_application_graph", + ].includes(evidence.operation) + ) + return; + try { + const source = parseApplicationGraphEvidence(evidence); + for (const node of source.graph.nodes) { + const candidatePolicy = applicationObligationPolicy(node.kind); + if (candidatePolicy === undefined) continue; + const reference = node.observations.map((observation) => + graphReference( + evidence, + records, + observation.evidence, + `${source.graph.graph_id}/node/${node.node_id}/observation/${observation.observation_id}`, + ), + ); + addCandidate( + candidates, + generatedCandidate({ + key: `application:${node.node_id}:${candidatePolicy.family}`, + title: node.observations[0]?.label ?? `${node.kind} boundary`, + policy: candidatePolicy, + artifactSha256: source.rootArtifactSha256, + applicationNodeId: node.node_id, + semanticNodeId: null, + location: `application-graph:${source.graph.graph_id}/nodes/${node.node_id}`, + authorityReferences: reference, + }), + ); + } + if (source.semanticGraph !== null) + for (const node of source.semanticGraph.nodes) { + const candidatePolicy = semanticObligationPolicy(node.kind); + if (candidatePolicy === undefined) continue; + addCandidate( + candidates, + generatedCandidate({ + key: `semantic:${node.node_id}:${candidatePolicy.family}`, + title: node.label ?? `${node.kind} semantic boundary`, + policy: candidatePolicy, + artifactSha256: node.identity.artifact_sha256, + applicationNodeId: node.application_node_ids[0] ?? null, + semanticNodeId: node.node_id, + location: `semantic-graph:${source.semanticGraph.graph_id}/nodes/${node.node_id}`, + authorityReferences: [ + graphReference( + evidence, + records, + node.evidence, + `${source.semanticGraph.graph_id}/node/${node.node_id}`, + ), + ], + }), + ); + } + if (source.graph.coverage.status !== "complete") + limitations.add( + `Application graph ${source.graph.graph_id} coverage is ${source.graph.coverage.status}; omitted candidates remain unknown.`, + ); + } catch (cause: unknown) { + limitations.add( + `Application Evidence ${evidence.evidence_id} could not generate candidates: ${errorMessage(cause)}`, + ); + } +}; + +const deriveProcessCandidate = ( + evidence: Evidence, + candidates: Map, + limitations: Set, +): void => { + if ( + evidence.operation !== "capture_process_scenario" || + evidence.predicate_type !== "rea.process-capture/v4" + ) + return; + try { + const capture = parseProcessCapture(evidence.normalized_result); + const observedCases: ReconstructionObligation["observed_cases"] = [ + { + kind: capture.exit.code === 0 ? "positive" : "negative", + evidence_id: evidence.evidence_id, + location: "/normalized_result/exit", + }, + { + kind: "teardown", + evidence_id: evidence.evidence_id, + location: "/normalized_result/settlement", + }, + ...(capture.exit.signal !== null || capture.exit.reason !== "exited" + ? [ + { + kind: "cancellation" as const, + evidence_id: evidence.evidence_id, + location: "/normalized_result/exit", + }, + ] + : []), + ]; + addCandidate(candidates, { + obligation_id: obligationId( + `process:${capture.manifest.executable_sha256}:${capture.manifest.full_scenario_sha256}`, + ), + obligation_version: 1, + title: "Captured packaged-process lifecycle", + origin: "generated", + application_layer: "process", + family: "packaged-process-lifecycle", + target: { + artifact_sha256: capture.manifest.executable_sha256, + application_node_id: null, + semantic_node_id: null, + location: `/evidence/${evidence.evidence_id}/normalized_result`, + }, + authority_references: [ + { + evidence_id: evidence.evidence_id, + authority: evidence.authority, + state: "observed", + location: "/normalized_result", + }, + ], + source_state: "observed", + observed_cases: observedCases, + required: true, + required_case_kinds: ["positive", "negative", "cancellation", "teardown"], + required_original_authority: "process", + required_fixture_authority: "packaged-process", + required_verifier_authority: "packaged-process", + requires_parser_type: false, + dependency_obligation_ids: [], + residual_unknown_ids: [], + unavailable_authority: capture.residual_unknowns.map( + ({ scope, reason }) => `${scope}: ${reason}`, + ), + required_next_evidence: capture.truncated + ? ["Repeat the packaged-process capture without truncation."] + : [], + definitionCount: 1, + disposition: "active", + }); + if (capture.truncated) + limitations.add( + `Process Evidence ${evidence.evidence_id} is truncated; absence and upper bounds remain unknown.`, + ); + } catch (cause: unknown) { + limitations.add( + `Process Evidence ${evidence.evidence_id} could not generate candidates: ${errorMessage(cause)}`, + ); + } +}; + +interface GeneratedCandidateInput { + readonly key: string; + readonly title: string; + readonly policy: ReconstructionObligationCandidatePolicy; + readonly artifactSha256: string; + readonly applicationNodeId: string | null; + readonly semanticNodeId: string | null; + readonly location: string; + readonly authorityReferences: ReconstructionObligation["authority_references"]; +} + +const generatedCandidate = ({ + key, + title, + policy: candidatePolicy, + artifactSha256, + applicationNodeId, + semanticNodeId, + location, + authorityReferences, +}: GeneratedCandidateInput): ReconstructionObligationCandidate => ({ + obligation_id: obligationId(key), + obligation_version: 1, + title: title.slice(0, 500), + origin: "generated", + application_layer: candidatePolicy.applicationLayer, + family: candidatePolicy.family, + target: { + artifact_sha256: artifactSha256, + application_node_id: applicationNodeId, + semantic_node_id: semanticNodeId, + location, + }, + authority_references: authorityReferences, + source_state: sourceState(authorityReferences), + observed_cases: [], + required: true, + required_case_kinds: candidatePolicy.cases, + required_original_authority: candidatePolicy.originalAuthority, + required_fixture_authority: candidatePolicy.fixtureAuthority, + required_verifier_authority: candidatePolicy.verifierAuthority, + requires_parser_type: candidatePolicy.requiresParserType, + dependency_obligation_ids: [], + residual_unknown_ids: [], + unavailable_authority: [], + required_next_evidence: [ + `Obtain ${candidatePolicy.originalAuthority} authority for this finite ${candidatePolicy.family} claim.`, + ], + definitionCount: 1, + disposition: "active", +}); + +const reviewedCandidate = ( + obligation: ReviewedReconstructionObligation, + records: ReadonlyMap, +): ReconstructionObligationCandidate => { + const { + review_evidence_ids: reviewEvidenceIds, + disposition, + ...definition + } = obligation; + return { + ...definition, + origin: "reviewed", + authority_references: reviewEvidenceIds.map((evidenceId) => { + const evidence = records.get(evidenceId); + if (evidence === undefined) + throw new TypeError( + `Reviewed obligation Evidence is missing: ${evidenceId}`, + ); + return { + evidence_id: evidenceId, + authority: evidence.authority, + state: reviewedEvidenceState(evidence), + location: `/evidence/${evidenceId}`, + }; + }), + source_state: "reviewed", + observed_cases: [], + definitionCount: 1, + disposition, + }; +}; + +const graphReference = ( + envelope: Evidence, + records: ReadonlyMap, + graphEvidence: { + readonly authority: string; + readonly state: string; + readonly evidence_ids: readonly string[]; + }, + location: string, +): ReconstructionObligation["authority_references"][number] => { + const linkedId = + graphEvidence.evidence_ids.find((evidenceId) => records.has(evidenceId)) ?? + envelope.evidence_id; + const linked = records.get(linkedId) ?? envelope; + const runtimeAuthority = [ + "passive-cdp-runtime", + "controlled-replay", + ].includes(graphEvidence.authority); + return { + evidence_id: linked.evidence_id, + authority: linked.authority, + state: + runtimeAuthority && graphEvidence.state === "observed" + ? "observed" + : graphEvidence.state === "unknown" || + graphEvidence.state === "unavailable" + ? "unknown" + : "candidate", + location, + }; +}; + +const reviewedEvidenceState = ( + evidence: Evidence, +): ReconstructionObligation["authority_references"][number]["state"] => { + if ( + evidence.operation === "capture_process_scenario" && + evidence.predicate_type === "rea.process-capture/v4" && + evidence.confidence === "observed" + ) + return "observed"; + if ( + evidence.authority === "external-service" && + evidence.confidence === "observed" + ) + return "observed"; + if (evidence.authority === "shipped-artifact") return "candidate"; + return "reviewed"; +}; + +const addCandidate = ( + candidates: Map, + candidate: ReconstructionObligationCandidate, +): void => { + const current = candidates.get(candidate.obligation_id); + if (current === undefined) { + candidates.set(candidate.obligation_id, candidate); + return; + } + candidates.set(candidate.obligation_id, { + ...current, + authority_references: uniqueBy( + [...current.authority_references, ...candidate.authority_references], + (reference) => + `${reference.evidence_id}:${reference.state}:${reference.location}`, + ), + observed_cases: uniqueBy( + [...current.observed_cases, ...candidate.observed_cases], + (item) => `${item.kind}:${item.evidence_id}:${item.location}`, + ), + source_state: sourceState([ + ...current.authority_references, + ...candidate.authority_references, + ]), + definitionCount: current.definitionCount + candidate.definitionCount, + }); +}; + +const sourceState = ( + references: ReconstructionObligation["authority_references"], +): ReconstructionObligation["source_state"] => { + if (references.some(({ state }) => state === "observed")) return "observed"; + if (references.some(({ state }) => state === "candidate")) return "candidate"; + if (references.some(({ state }) => state === "reviewed")) return "reviewed"; + return "unknown"; +}; + +const uniqueBy = ( + values: readonly Value[], + key: (value: Value) => string, +): Value[] => + [...new Map(values.map((value) => [key(value), value])).values()].sort( + (left, right) => key(left).localeCompare(key(right)), + ); + +const obligationId = (key: string): string => + `obl_${digest({ schema: "rea.reconstruction-obligation/v1", key })}`; + +const digest = (value: unknown): string => { + const encoded = canonicalize(value); + if (encoded === undefined) + throw new TypeError("Reconstruction obligation key is not canonical JSON"); + return createHash("sha256").update(encoded).digest("hex"); +}; + +const errorMessage = (cause: unknown): string => + cause instanceof Error ? cause.message : "unknown validation failure"; diff --git a/src/application/ReconstructionObligationLedgerDiagnostics.ts b/src/application/ReconstructionObligationLedgerDiagnostics.ts new file mode 100644 index 000000000..d92fd21ef --- /dev/null +++ b/src/application/ReconstructionObligationLedgerDiagnostics.ts @@ -0,0 +1,87 @@ +import type { + ReconstructionObligation, + ReconstructionObligationManifest, +} from "../domain/reconstructionObligationLedgerSchemas.js"; +import type { Evidence } from "../domain/evidence.js"; + +type Diagnostic = ReconstructionObligation["diagnostics"][number]; +type Binding = NonNullable; + +export const groupManifestBindings = ( + manifest: ReconstructionObligationManifest, +): ReadonlyMap => { + const grouped = new Map(); + for (const binding of manifest.bindings) + grouped.set(binding.obligation_id, [ + ...(grouped.get(binding.obligation_id) ?? []), + binding, + ]); + return grouped; +}; + +export const groupManifestContradictions = ( + manifest: ReconstructionObligationManifest, +): ReadonlyMap => { + const grouped = new Map(); + for (const contradiction of manifest.contradictions) + grouped.set(contradiction.obligation_id, [ + ...(grouped.get(contradiction.obligation_id) ?? []), + ...contradiction.evidence_ids, + ]); + return grouped; +}; + +export const addObligationDiagnostic = ( + diagnostics: Diagnostic[], + code: Diagnostic["code"], + detail: string, +): void => { + diagnostics.push({ code, detail }); +}; + +export const uniqueObligationDiagnostics = ( + diagnostics: readonly Diagnostic[], +): Diagnostic[] => + [ + ...new Map( + diagnostics.map((diagnostic) => [ + `${diagnostic.code}:${diagnostic.detail}`, + diagnostic, + ]), + ).values(), + ].sort((left, right) => + `${left.code}:${left.detail}`.localeCompare( + `${right.code}:${right.detail}`, + ), + ); + +export const hasObligationDiagnostic = ( + diagnostics: readonly Diagnostic[], + code: Diagnostic["code"], +): boolean => diagnostics.some((diagnostic) => diagnostic.code === code); + +export const contradictionDiagnostics = ( + obligationId: string, + contradictions: ReadonlyMap, + evidenceById: ReadonlyMap, +): Diagnostic[] => { + const evidenceIds = contradictions.get(obligationId) ?? []; + if (evidenceIds.length === 0) return []; + const missing = evidenceIds.filter( + (evidenceId) => !evidenceById.has(evidenceId), + ); + return missing.length > 0 + ? [ + { + code: "contradiction-evidence-missing", + detail: `Contradiction references missing Evidence: ${missing.join(", ")}.`, + }, + ] + : [ + { + code: "contradiction", + detail: + "Authenticated contradictory Evidence reopens this obligation.", + }, + ]; +}; diff --git a/src/application/ReconstructionObligationLedgerEvaluation.ts b/src/application/ReconstructionObligationLedgerEvaluation.ts new file mode 100644 index 000000000..8780c1357 --- /dev/null +++ b/src/application/ReconstructionObligationLedgerEvaluation.ts @@ -0,0 +1,506 @@ +import type { Evidence } from "../domain/evidence.js"; +import { + parseEvidenceBundle, + type EvidenceBundle, +} from "../domain/evidenceBundle.js"; +import { + reconstructionObligationLedgerSchema, + type ReconstructionObligation, + type ReconstructionObligationLedger, + type ReconstructionObligationManifest, +} from "../domain/reconstructionObligationLedgerSchemas.js"; +import type { ReconstructionObligationCandidate } from "./ReconstructionObligationCandidates.js"; +import { + evidenceAuthoritySupportsProof, + evidenceSupportsOriginalAuthority, + proofAuthoritySatisfies, +} from "./ReconstructionObligationAuthority.js"; +import { + addObligationDiagnostic as addDiagnostic, + contradictionDiagnostics, + groupManifestBindings, + groupManifestContradictions, + hasObligationDiagnostic as hasDiagnostic, + uniqueObligationDiagnostics as uniqueDiagnostics, +} from "./ReconstructionObligationLedgerDiagnostics.js"; +import { + originalCaseDiagnostics, + originalCasesFor, +} from "./ReconstructionObligationOriginalCases.js"; +import { + digestObligationLedgerValue, + obligationLedgerCoverage, + obligationEdgeOrder, + obligationLedgerEvidenceLinks, + obligationLedgerStatus, + obligationReports, + summarizeObligations, +} from "./ReconstructionObligationLedgerProjection.js"; + +type Diagnostic = ReconstructionObligation["diagnostics"][number]; +type Binding = NonNullable; + +interface EvaluationContext { + readonly candidates: ReadonlyMap; + readonly evidence: ReadonlyMap; + readonly bindings: ReadonlyMap; + readonly contradictions: ReadonlyMap; +} + +export const evaluateReconstructionObligationLedger = ({ + candidates: candidatesInput, + bundle: bundleInput, + manifest, + maxObligations, + generationLimitations, +}: { + readonly candidates: readonly ReconstructionObligationCandidate[]; + readonly bundle: EvidenceBundle; + readonly manifest: ReconstructionObligationManifest; + readonly maxObligations: number; + readonly generationLimitations: readonly string[]; +}): ReconstructionObligationLedger => { + const bundle = parseEvidenceBundle(bundleInput); + const allCandidates = [...candidatesInput].sort((left, right) => + left.obligation_id.localeCompare(right.obligation_id), + ); + const orphanManifestIds = findOrphanManifestIds(allCandidates, manifest); + const candidates = allCandidates.slice(0, maxObligations); + const omittedCount = allCandidates.length - candidates.length; + const context = createEvaluationContext(candidates, bundle, manifest); + const obligations = applyDependencyDiagnostics( + candidates.map((candidate) => evaluateObligation(candidate, context)), + context, + ); + const ownershipGraph = obligations + .flatMap((obligation) => + obligation.binding === null + ? [] + : [ + { + obligation_id: obligation.obligation_id, + module_path: obligation.binding.owner.module_path, + symbol: obligation.binding.owner.symbol, + }, + ], + ) + .sort(obligationEdgeOrder); + const dependencyGraph = obligations + .flatMap((obligation) => + obligation.dependency_obligation_ids.map((dependencyId) => ({ + obligation_id: obligation.obligation_id, + depends_on_obligation_id: dependencyId, + })), + ) + .sort(obligationEdgeOrder); + const evidenceLinks = obligationLedgerEvidenceLinks(obligations, manifest); + const limitations = ledgerLimitations( + generationLimitations, + allCandidates.length, + omittedCount, + orphanManifestIds, + ); + const coverage = obligationLedgerCoverage( + omittedCount, + maxObligations, + limitations.length > 0, + ); + const semantic = { + coverage, + summary: summarizeObligations(obligations), + reports: obligationReports(obligations), + ownership_graph: ownershipGraph, + dependency_graph: dependencyGraph, + obligations, + evidence_links: evidenceLinks, + limitations, + }; + const evaluatedStatus = obligationLedgerStatus(obligations, omittedCount); + const status = + evaluatedStatus === "failed" + ? evaluatedStatus + : limitations.length > 0 + ? "unknown" + : evaluatedStatus; + const closureDigest = digestObligationLedgerValue({ + schema: "rea.reconstruction-obligation-closure/v1", + status, + ...semantic, + }); + return reconstructionObligationLedgerSchema.parse({ + schema: "ReconstructionObligationLedger", + schema_version: 1, + ledger_id: `rol_${digestObligationLedgerValue({ + schema: "rea.reconstruction-obligation-ledger/v1", + evidence_ids: bundle.records.map(({ evidence_id: id }) => id), + obligation_ids: obligations.map(({ obligation_id: id }) => id), + closure_digest: closureDigest, + })}`, + closure_digest: closureDigest, + status, + ...semantic, + }); +}; + +const createEvaluationContext = ( + candidates: readonly ReconstructionObligationCandidate[], + bundle: EvidenceBundle, + manifest: ReconstructionObligationManifest, +): EvaluationContext => ({ + candidates: new Map( + candidates.map((candidate) => [candidate.obligation_id, candidate]), + ), + evidence: new Map( + bundle.records.map((evidence) => [evidence.evidence_id, evidence]), + ), + bindings: groupManifestBindings(manifest), + contradictions: groupManifestContradictions(manifest), +}); + +const findOrphanManifestIds = ( + candidates: readonly ReconstructionObligationCandidate[], + manifest: ReconstructionObligationManifest, +): string[] => { + const candidateIds = new Set( + candidates.map(({ obligation_id: obligationId }) => obligationId), + ); + return [ + ...new Set( + [ + ...manifest.bindings.map( + ({ obligation_id: obligationId }) => obligationId, + ), + ...manifest.contradictions.map( + ({ obligation_id: obligationId }) => obligationId, + ), + ].filter((obligationId) => !candidateIds.has(obligationId)), + ), + ].sort(); +}; + +const ledgerLimitations = ( + generationLimitations: readonly string[], + candidateCount: number, + omittedCount: number, + orphanManifestIds: readonly string[], +): string[] => + [ + ...new Set([ + ...generationLimitations, + ...(candidateCount === 0 + ? [ + "No reconstruction obligations were derived; closure cannot be claimed.", + ] + : []), + ...(omittedCount > 0 + ? [ + `${String(omittedCount)} obligation candidates were omitted by max_obligations; closure is unknown.`, + ] + : []), + ...(orphanManifestIds.length > 0 + ? [ + `Manifest entries reference unknown obligations: ${orphanManifestIds.join(", ")}.`, + ] + : []), + ]), + ].sort(); + +const evaluateObligation = ( + candidate: ReconstructionObligationCandidate, + context: EvaluationContext, +): ReconstructionObligation => { + const bindings = context.bindings.get(candidate.obligation_id) ?? []; + const binding = bindings.length === 1 ? (bindings[0] ?? null) : null; + const diagnostics: Diagnostic[] = []; + if (candidate.definitionCount > 1) + addDiagnostic( + diagnostics, + "duplicate-definition", + "Multiple candidate definitions share this obligation ID.", + ); + if (bindings.length === 0) + addDiagnostic( + diagnostics, + "missing-owner", + "No reconstruction manifest binding owns this obligation.", + ); + if (bindings.length > 1) + addDiagnostic( + diagnostics, + "ambiguous-owner", + "Multiple reconstruction manifest bindings claim this obligation.", + ); + if (binding !== null) + evaluateBinding(candidate, binding, context, diagnostics); + diagnostics.push( + ...originalCaseDiagnostics(candidate, binding, context.evidence), + ); + evaluateAuthority(candidate, binding, context, diagnostics); + evaluateDisposition(candidate, diagnostics); + diagnostics.push( + ...contradictionDiagnostics( + candidate.obligation_id, + context.contradictions, + context.evidence, + ), + ); + for (const unknownId of candidate.residual_unknown_ids) + addDiagnostic( + diagnostics, + "residual-unknown", + `Residual unknown remains visible: ${unknownId}.`, + ); + for (const unavailable of candidate.unavailable_authority) + addDiagnostic(diagnostics, "unavailable-authority", unavailable); + return { + obligation_id: candidate.obligation_id, + obligation_version: candidate.obligation_version, + title: candidate.title, + origin: candidate.origin, + application_layer: candidate.application_layer, + family: candidate.family, + target: candidate.target, + authority_references: candidate.authority_references, + source_state: candidate.source_state, + observed_cases: originalCasesFor(candidate, binding), + required: candidate.required, + required_case_kinds: candidate.required_case_kinds, + required_original_authority: candidate.required_original_authority, + required_fixture_authority: candidate.required_fixture_authority, + required_verifier_authority: candidate.required_verifier_authority, + requires_parser_type: candidate.requires_parser_type, + dependency_obligation_ids: candidate.dependency_obligation_ids, + residual_unknown_ids: candidate.residual_unknown_ids, + unavailable_authority: candidate.unavailable_authority, + required_next_evidence: candidate.required_next_evidence, + binding, + status: obligationStatus(candidate, binding, diagnostics), + diagnostics: uniqueDiagnostics(diagnostics), + }; +}; + +const evaluateBinding = ( + candidate: ReconstructionObligationCandidate, + binding: Binding, + context: EvaluationContext, + diagnostics: Diagnostic[], +): void => { + if (candidate.requires_parser_type && binding.parser_type === null) + addDiagnostic( + diagnostics, + "missing-parser-type", + "This boundary requires an explicit parser, schema, and domain type.", + ); + for (const caseKind of candidate.required_case_kinds) { + const fixtures = binding.fixtures.filter( + ({ case_kind: fixtureCase }) => fixtureCase === caseKind, + ); + if (fixtures.length === 0) { + addDiagnostic( + diagnostics, + "missing-case", + `Required ${caseKind} fixture is missing.`, + ); + continue; + } + if ( + !fixtures.some( + (fixture) => + proofAuthoritySatisfies( + fixture.authority, + candidate.required_fixture_authority, + ) && + fixture.evidence_ids.every((id) => { + const evidence = context.evidence.get(id); + return ( + evidence !== undefined && + evidenceAuthoritySupportsProof(evidence, fixture.authority, { + obligationId: candidate.obligation_id, + fixtureId: fixture.fixture_id, + caseKind: fixture.case_kind, + }) + ); + }), + ) + ) + addDiagnostic( + diagnostics, + "weak-fixture-authority", + `${caseKind} fixture lacks ${candidate.required_fixture_authority} authority or authenticated Evidence.`, + ); + } + evaluateVerifier(candidate, binding, context, diagnostics); +}; + +const evaluateVerifier = ( + candidate: ReconstructionObligationCandidate, + binding: Binding, + context: EvaluationContext, + diagnostics: Diagnostic[], +): void => { + const verifier = binding.verifier; + if (verifier === null) { + addDiagnostic( + diagnostics, + "missing-verifier", + "No verifier claim is bound to this obligation.", + ); + return; + } + if (!verifier.enumerated_obligation_ids.includes(candidate.obligation_id)) + addDiagnostic( + diagnostics, + "verifier-does-not-enumerate", + "The verifier claim does not enumerate this obligation ID.", + ); + const resultEvidence = context.evidence.get(verifier.result_evidence_id); + if (resultEvidence === undefined) + addDiagnostic( + diagnostics, + "missing-verifier-result", + "The latest verifier result Evidence is absent from the bundle.", + ); + if ( + !proofAuthoritySatisfies( + verifier.authority, + candidate.required_verifier_authority, + ) || + (resultEvidence !== undefined && + !evidenceAuthoritySupportsProof(resultEvidence, verifier.authority, { + obligationId: candidate.obligation_id, + verifierId: verifier.verifier_id, + claimId: verifier.claim_id, + })) + ) + addDiagnostic( + diagnostics, + "weak-verifier-authority", + `Verifier authority does not satisfy ${candidate.required_verifier_authority}.`, + ); + if (verifier.status !== "pass") + addDiagnostic( + diagnostics, + "verifier-failed", + `Latest verifier status is ${verifier.status}.`, + ); +}; + +const evaluateAuthority = ( + candidate: ReconstructionObligationCandidate, + binding: Binding | null, + context: EvaluationContext, + diagnostics: Diagnostic[], +): void => { + const referenceSatisfied = candidate.authority_references.some( + (reference) => { + const evidence = context.evidence.get(reference.evidence_id); + return ( + evidence !== undefined && + (candidate.required_original_authority === "static" + ? ["candidate", "observed"].includes(reference.state) + : reference.state === "observed") && + evidenceSupportsOriginalAuthority( + evidence, + candidate.required_original_authority, + ) + ); + }, + ); + const caseSatisfied = originalCasesFor(candidate, binding).some( + ({ evidence_id: evidenceId }) => { + const evidence = context.evidence.get(evidenceId); + return ( + evidence !== undefined && + evidenceSupportsOriginalAuthority( + evidence, + candidate.required_original_authority, + ) + ); + }, + ); + const satisfied = referenceSatisfied || caseSatisfied; + if (!satisfied) + addDiagnostic( + diagnostics, + "static-only", + `No ${candidate.required_original_authority} authority characterizes the original finite behavior.`, + ); +}; + +const evaluateDisposition = ( + candidate: ReconstructionObligationCandidate, + diagnostics: Diagnostic[], +): void => { + if (candidate.disposition === "blocked") + addDiagnostic( + diagnostics, + "blocked", + "Reviewed disposition marks this obligation blocked.", + ); + if (candidate.disposition === "out-of-scope") + addDiagnostic( + diagnostics, + "out-of-scope", + "Reviewed disposition marks this obligation out of scope; it remains visible.", + ); +}; + +const applyDependencyDiagnostics = ( + obligations: readonly ReconstructionObligation[], + context: EvaluationContext, +): ReconstructionObligation[] => { + let evaluated = [...obligations]; + for (let pass = 0; pass < obligations.length; pass += 1) { + const statuses = new Map( + evaluated.map(({ obligation_id: id, status }) => [id, status]), + ); + let changed = false; + evaluated = evaluated.map((obligation) => { + const diagnostics = [...obligation.diagnostics]; + for (const dependencyId of obligation.dependency_obligation_ids) { + const missing = !context.candidates.has(dependencyId); + if (missing || statuses.get(dependencyId) !== "verified") + addDiagnostic( + diagnostics, + missing ? "dependency-missing" : "dependency-open", + `Dependency obligation is ${missing ? "missing" : "not verified"}: ${dependencyId}.`, + ); + } + const normalized = uniqueDiagnostics(diagnostics); + const status = dependencyStatus(obligation.status, normalized); + changed ||= status !== obligation.status; + return { ...obligation, status, diagnostics: normalized }; + }); + if (!changed) break; + } + return evaluated; +}; + +const obligationStatus = ( + candidate: ReconstructionObligationCandidate, + binding: Binding | null, + diagnostics: readonly Diagnostic[], +): ReconstructionObligation["status"] => { + if (hasDiagnostic(diagnostics, "out-of-scope")) return "out-of-scope"; + if (hasDiagnostic(diagnostics, "blocked")) return "blocked"; + if (hasDiagnostic(diagnostics, "contradiction")) return "contradicted"; + if ( + hasDiagnostic(diagnostics, "duplicate-definition") || + hasDiagnostic(diagnostics, "residual-unknown") || + hasDiagnostic(diagnostics, "unavailable-authority") + ) + return "unknown"; + if (binding === null) return "unowned"; + if (diagnostics.length === 0) return "verified"; + return "implemented"; +}; + +const dependencyStatus = ( + status: ReconstructionObligation["status"], + diagnostics: readonly Diagnostic[], +): ReconstructionObligation["status"] => + (hasDiagnostic(diagnostics, "dependency-missing") || + hasDiagnostic(diagnostics, "dependency-open")) && + !["contradicted", "out-of-scope"].includes(status) + ? "blocked" + : status; diff --git a/src/application/ReconstructionObligationLedgerProjection.ts b/src/application/ReconstructionObligationLedgerProjection.ts new file mode 100644 index 000000000..872f4f01e --- /dev/null +++ b/src/application/ReconstructionObligationLedgerProjection.ts @@ -0,0 +1,164 @@ +import { createHash } from "node:crypto"; + +import canonicalize from "canonicalize"; + +import type { + ReconstructionObligation, + ReconstructionObligationLedger, + ReconstructionObligationManifest, +} from "../domain/reconstructionObligationLedgerSchemas.js"; + +type DiagnosticCode = ReconstructionObligation["diagnostics"][number]["code"]; + +export const obligationLedgerCoverage = ( + omittedCount: number, + maxObligations: number, + incomplete: boolean, +): ReconstructionObligationLedger["coverage"] => ({ + status: incomplete ? "partial" : "complete", + truncated: omittedCount > 0, + omitted_count: omittedCount, + max_obligations: maxObligations, +}); + +export const summarizeObligations = ( + obligations: readonly ReconstructionObligation[], +): ReconstructionObligationLedger["summary"] => ({ + total: obligations.length, + required: obligations.filter(({ required }) => required).length, + verified: obligations.filter(({ status }) => status === "verified").length, + required_open: obligations.filter( + ({ required, status }) => required && status !== "verified", + ).length, + by_status: countBy( + obligations, + [ + "unowned", + "characterized", + "implemented", + "verified", + "contradicted", + "blocked", + "out-of-scope", + "unknown", + ], + ({ status }) => status, + ), + by_application_layer: countBy( + obligations, + [ + "cli", + "protocol", + "electron", + "persistence", + "process", + "runtime", + "packaging", + "native-abi", + "application", + "other", + ], + ({ application_layer: layer }) => layer, + ), + by_evidence_authority: countBy( + obligations, + ["candidate", "observed", "reviewed", "unknown"], + ({ source_state: state }) => state, + ), +}); + +export const obligationReports = ( + obligations: readonly ReconstructionObligation[], +): ReconstructionObligationLedger["reports"] => ({ + missing_owner_obligation_ids: obligationIdsWith(obligations, "missing-owner"), + missing_verifier_obligation_ids: obligationIdsWith( + obligations, + "missing-verifier", + ), + contradicted_obligation_ids: obligationIdsWith(obligations, "contradiction"), + residual_unknown_ids: [ + ...new Set(obligations.flatMap(({ residual_unknown_ids: ids }) => ids)), + ].sort(), +}); + +export const obligationLedgerEvidenceLinks = ( + obligations: readonly ReconstructionObligation[], + manifest: ReconstructionObligationManifest, +): string[] => + [ + ...new Set([ + ...obligations.flatMap(({ authority_references: references }) => + references.map(({ evidence_id: id }) => id), + ), + ...manifest.bindings.flatMap((binding) => [ + ...binding.original_cases.map(({ evidence_id: id }) => id), + ...binding.fixtures.flatMap(({ evidence_ids: ids }) => ids), + ...(binding.verifier === null + ? [] + : [binding.verifier.result_evidence_id]), + ]), + ...manifest.contradictions.flatMap(({ evidence_ids: ids }) => ids), + ]), + ].sort(); + +export const obligationLedgerStatus = ( + obligations: readonly ReconstructionObligation[], + omittedCount: number, +): ReconstructionObligationLedger["status"] => { + if (obligations.length === 0) return "unknown"; + const requiredOpen = obligations.filter( + ({ required, status }) => required && status !== "verified", + ); + if ( + requiredOpen.some( + ({ status, diagnostics }) => + status === "contradicted" || + hasDiagnostic(diagnostics, "verifier-failed"), + ) + ) + return "failed"; + if ( + omittedCount > 0 || + requiredOpen.some(({ status }) => status === "unknown") + ) + return "unknown"; + return requiredOpen.length === 0 ? "ready" : "open"; +}; + +export const obligationEdgeOrder = ( + left: { readonly obligation_id: string }, + right: { readonly obligation_id: string }, +): number => JSON.stringify(left).localeCompare(JSON.stringify(right)); + +export const digestObligationLedgerValue = (value: unknown): string => { + const encoded = canonicalize(value); + if (encoded === undefined) + throw new TypeError( + "Reconstruction obligation ledger is not canonical JSON", + ); + return createHash("sha256").update(encoded).digest("hex"); +}; + +const countBy = ( + items: readonly Item[], + values: readonly Value[], + valueFor: (item: Item) => Value, +): { key: Value; count: number }[] => + values.map((value) => ({ + key: value, + count: items.filter((item) => valueFor(item) === value).length, + })); + +const obligationIdsWith = ( + obligations: readonly ReconstructionObligation[], + code: DiagnosticCode, +): string[] => + obligations + .filter(({ diagnostics }) => hasDiagnostic(diagnostics, code)) + .map(({ obligation_id: id }) => id) + .sort(); + +const hasDiagnostic = ( + diagnostics: ReconstructionObligation["diagnostics"], + code: DiagnosticCode, +): boolean => diagnostics.some((diagnostic) => diagnostic.code === code); diff --git a/src/application/ReconstructionObligationLedgerService.ts b/src/application/ReconstructionObligationLedgerService.ts new file mode 100644 index 000000000..b686e46f3 --- /dev/null +++ b/src/application/ReconstructionObligationLedgerService.ts @@ -0,0 +1,107 @@ +import { z } from "zod"; + +import { + AnalysisInputError, + AnalysisProtocolError, + type AnalysisError, +} from "../domain/errors.js"; +import { createEvidence, type Evidence } from "../domain/evidence.js"; +import { jsonObjectSchema, jsonValueSchema } from "../domain/jsonValue.js"; +import { + reconstructionObligationLedgerInputSchema, + reconstructionObligationLedgerPageSchema, + type ReconstructionObligationLedgerInput, + type ReconstructionObligationLedgerPage, +} from "../domain/reconstructionObligationLedgerSchemas.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER } from "./InvestigationProviders.js"; +import { deriveReconstructionObligationCandidates } from "./ReconstructionObligationCandidates.js"; +import { evaluateReconstructionObligationLedger } from "./ReconstructionObligationLedgerEvaluation.js"; + +const OPERATION = "build_reconstruction_obligation_ledger" as const; + +/** Parse one portable ledger request for both CLI and MCP adapters. */ +export const resolveReconstructionObligationLedgerRequest = ( + input: unknown, +): Result => { + const parsed = reconstructionObligationLedgerInputSchema.safeParse(input); + return parsed.success + ? ok(parsed.data) + : err(new AnalysisInputError(OPERATION, { cause: parsed.error })); +}; + +/** Build one deterministic ledger page and wrap it in portable Evidence v2. */ +export const buildReconstructionObligationLedgerEvidenceValidated = ( + input: ReconstructionObligationLedgerInput, +): Result => { + try { + const generated = deriveReconstructionObligationCandidates( + input.evidence_bundle, + input.reviewed_obligations, + ); + const ledger = evaluateReconstructionObligationLedger({ + candidates: generated.candidates, + bundle: input.evidence_bundle, + manifest: input.manifest, + maxObligations: input.limits.max_obligations, + generationLimitations: generated.limitations, + }); + const page = pageLedger(ledger, input.page.offset, input.page.limit); + return ok(createLedgerEvidence(input, page)); + } catch (cause: unknown) { + if (cause instanceof z.ZodError) + return err(new AnalysisInputError(OPERATION, { cause })); + return err( + new AnalysisProtocolError( + "Reconstruction obligation ledger generation failed", + { cause }, + ), + ); + } +}; + +const pageLedger = ( + ledger: ReturnType, + offset: number, + limit: number, +): ReconstructionObligationLedgerPage => { + const obligations = ledger.obligations.slice(offset, offset + limit); + const nextOffset = + offset + obligations.length < ledger.obligations.length + ? offset + obligations.length + : null; + const { obligations: _allObligations, ...summary } = ledger; + return reconstructionObligationLedgerPageSchema.parse({ + ...summary, + page: { + offset, + limit, + total: ledger.obligations.length, + returned: obligations.length, + next_offset: nextOffset, + }, + obligations, + }); +}; + +const createLedgerEvidence = ( + input: ReconstructionObligationLedgerInput, + page: ReconstructionObligationLedgerPage, +): Evidence => + createEvidence(undefined, JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER, { + predicateType: "rea.reconstruction-obligation-ledger/v1", + operation: OPERATION, + parameters: jsonObjectSchema.parse({ + ledger_id: page.ledger_id, + closure_digest: page.closure_digest, + limits: input.limits, + page: input.page, + }), + result: jsonValueSchema.parse(page), + rawResult: null, + confidence: "inferred", + authority: "analyst-inference", + environment: null, + limitations: page.limitations, + evidenceLinks: page.evidence_links, + }); diff --git a/src/application/ReconstructionObligationOriginalCases.ts b/src/application/ReconstructionObligationOriginalCases.ts new file mode 100644 index 000000000..4d6cf3d38 --- /dev/null +++ b/src/application/ReconstructionObligationOriginalCases.ts @@ -0,0 +1,64 @@ +import type { Evidence } from "../domain/evidence.js"; +import type { ReconstructionObligation } from "../domain/reconstructionObligationLedgerSchemas.js"; +import { evidenceSupportsOriginalAuthority } from "./ReconstructionObligationAuthority.js"; +import type { ReconstructionObligationCandidate } from "./ReconstructionObligationCandidates.js"; + +type Binding = NonNullable; +type Diagnostic = ReconstructionObligation["diagnostics"][number]; + +/** Merge generated and manifest-supplied original observations canonically. */ +export const originalCasesFor = ( + candidate: ReconstructionObligationCandidate, + binding: Binding | null, +): ReconstructionObligation["observed_cases"] => + [ + ...new Map( + [...candidate.observed_cases, ...(binding?.original_cases ?? [])].map( + (observed) => [ + `${observed.kind}:${observed.evidence_id}:${observed.location}`, + observed, + ], + ), + ).values(), + ].sort((left, right) => + `${left.kind}:${left.evidence_id}:${left.location}`.localeCompare( + `${right.kind}:${right.evidence_id}:${right.location}`, + ), + ); + +/** Report original cases that are absent or lack comparable Evidence authority. */ +export const originalCaseDiagnostics = ( + candidate: ReconstructionObligationCandidate, + binding: Binding | null, + evidenceById: ReadonlyMap, +): Diagnostic[] => { + const diagnostics: Diagnostic[] = []; + const originalCases = originalCasesFor(candidate, binding); + for (const caseKind of candidate.required_case_kinds) { + const cases = originalCases.filter(({ kind }) => kind === caseKind); + if (cases.length === 0) { + diagnostics.push({ + code: "missing-original-case", + detail: `Original ${caseKind} behavior has not been observed.`, + }); + continue; + } + if ( + !cases.some(({ evidence_id: evidenceId }) => { + const evidence = evidenceById.get(evidenceId); + return ( + evidence !== undefined && + evidenceSupportsOriginalAuthority( + evidence, + candidate.required_original_authority, + ) + ); + }) + ) + diagnostics.push({ + code: "weak-original-case-authority", + detail: `Original ${caseKind} behavior lacks ${candidate.required_original_authority} Evidence.`, + }); + } + return diagnostics; +}; diff --git a/src/application/ReconstructionObligationPolicies.ts b/src/application/ReconstructionObligationPolicies.ts new file mode 100644 index 000000000..09275584b --- /dev/null +++ b/src/application/ReconstructionObligationPolicies.ts @@ -0,0 +1,169 @@ +import type { ReconstructionObligation } from "../domain/reconstructionObligationLedgerSchemas.js"; + +export interface ReconstructionObligationCandidatePolicy { + readonly applicationLayer: ReconstructionObligation["application_layer"]; + readonly family: string; + readonly fixtureAuthority: ReconstructionObligation["required_fixture_authority"]; + readonly verifierAuthority: ReconstructionObligation["required_verifier_authority"]; + readonly originalAuthority: ReconstructionObligation["required_original_authority"]; + readonly cases: ReconstructionObligation["required_case_kinds"]; + readonly requiresParserType: boolean; +} + +const APPLICATION_NODE_POLICIES: Readonly< + Partial> +> = { + package: policy("packaging", "package-lifecycle", "packaged-process", [ + "positive", + "negative", + "teardown", + ]), + installer: policy("packaging", "installer", "packaged-process", [ + "positive", + "negative", + "teardown", + ]), + "context-bridge-api": policy("electron", "context-bridge", "renderer", [ + "positive", + "negative", + "malformed", + ]), + "ipc-channel": policy("electron", "ipc", "renderer", [ + "positive", + "negative", + "malformed", + ]), + "ipc-handler": policy("electron", "ipc", "renderer", [ + "positive", + "negative", + "malformed", + ]), + endpoint: policy("protocol", "endpoint", "protocol", [ + "positive", + "negative", + "malformed", + ]), + storage: policy("persistence", "persistence", "integration", [ + "positive", + "negative", + "malformed", + ]), + worker: policy("process", "worker-lifecycle", "packaged-process", [ + "positive", + "cancellation", + "teardown", + ]), + "service-worker": policy("process", "worker-lifecycle", "packaged-process", [ + "positive", + "cancellation", + "teardown", + ]), + "native-addon": nativePolicy("native-addon"), + "native-export": nativePolicy("native-export"), + "managed-pinvoke-import": nativePolicy("managed-pinvoke"), + "managed-native-implementation": nativePolicy("managed-native"), +}; + +const SEMANTIC_NODE_POLICIES: Readonly< + Partial> +> = { + "child-process": policy("process", "child-process", "packaged-process", [ + "positive", + "negative", + "cancellation", + "teardown", + ]), + stdio: policy("process", "stdio", "packaged-process", [ + "positive", + "negative", + "teardown", + ]), + signal: policy("process", "signal", "packaged-process", [ + "positive", + "cancellation", + "teardown", + ]), + "config-source": parserPolicy("cli", "configuration", "integration"), + request: parserPolicy("protocol", "request", "protocol"), + response: parserPolicy("protocol", "response", "protocol"), + boundary: parserPolicy("application", "parser-boundary", "integration"), + resource: policy("persistence", "resource-lifecycle", "integration", [ + "positive", + "negative", + "teardown", + ]), + event: policy("runtime", "event", "packaged-process", [ + "positive", + "negative", + "cancellation", + ]), + timer: policy("runtime", "timer", "packaged-process", [ + "positive", + "cancellation", + "teardown", + ]), + promise: policy("runtime", "promise-ownership", "packaged-process", [ + "positive", + "negative", + "cancellation", + ]), + task: policy("runtime", "task-lifecycle", "packaged-process", [ + "positive", + "cancellation", + "teardown", + ]), +}; + +export const applicationObligationPolicy = ( + nodeKind: string, +): ReconstructionObligationCandidatePolicy | undefined => + APPLICATION_NODE_POLICIES[nodeKind]; + +export const semanticObligationPolicy = ( + nodeKind: string, +): ReconstructionObligationCandidatePolicy | undefined => + SEMANTIC_NODE_POLICIES[nodeKind]; + +function policy( + applicationLayer: ReconstructionObligationCandidatePolicy["applicationLayer"], + family: string, + authority: ReconstructionObligationCandidatePolicy["fixtureAuthority"], + cases: ReconstructionObligationCandidatePolicy["cases"], +): ReconstructionObligationCandidatePolicy { + return { + applicationLayer, + family, + fixtureAuthority: authority, + verifierAuthority: authority, + originalAuthority: "runtime", + cases, + requiresParserType: false, + }; +} + +function parserPolicy( + applicationLayer: ReconstructionObligationCandidatePolicy["applicationLayer"], + family: string, + authority: ReconstructionObligationCandidatePolicy["fixtureAuthority"], +): ReconstructionObligationCandidatePolicy { + return { + ...policy(applicationLayer, family, authority, [ + "positive", + "negative", + "malformed", + ]), + requiresParserType: true, + }; +} + +function nativePolicy(family: string): ReconstructionObligationCandidatePolicy { + return { + applicationLayer: "native-abi", + family, + fixtureAuthority: "native-abi", + verifierAuthority: "native-abi", + originalAuthority: "static", + cases: ["positive", "negative"], + requiresParserType: false, + }; +} diff --git a/src/application/ReconstructionReadinessService.ts b/src/application/ReconstructionReadinessService.ts new file mode 100644 index 000000000..e7da145ff --- /dev/null +++ b/src/application/ReconstructionReadinessService.ts @@ -0,0 +1,67 @@ +import { z } from "zod"; + +import { + AnalysisInputError, + AnalysisProtocolError, + type AnalysisError, +} from "../domain/errors.js"; +import { createEvidence, type Evidence } from "../domain/evidence.js"; +import { jsonObjectSchema, jsonValueSchema } from "../domain/jsonValue.js"; +import { createReconstructionReadinessReport } from "../domain/reconstructionReadiness.js"; +import { + reconstructionReadinessInputSchema, + type ReconstructionReadinessInput, +} from "../domain/reconstructionReadinessSchemas.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER } from "./InvestigationProviders.js"; + +const OPERATION = "evaluate_reconstruction_readiness" as const; + +/** Parse one portable readiness request for both CLI and MCP adapters. */ +export const resolveReconstructionReadinessRequest = ( + input: unknown, +): Result => { + const parsed = reconstructionReadinessInputSchema.safeParse(input); + return parsed.success + ? ok(parsed.data) + : err(new AnalysisInputError(OPERATION, { cause: parsed.error })); +}; + +/** Evaluate and wrap one deterministic readiness report in Evidence v2. */ +export const evaluateReconstructionReadinessValidated = ( + input: ReconstructionReadinessInput, +): Result => { + try { + const report = createReconstructionReadinessReport(input); + return ok( + createEvidence(undefined, JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER, { + predicateType: "rea.reconstruction-readiness-report/v1", + operation: OPERATION, + parameters: jsonObjectSchema.parse({ + source_digest: report.source_digest, + expected_source_digest: input.replay.expected_source_digest, + }), + result: jsonValueSchema.parse(report), + rawResult: null, + confidence: "inferred", + authority: "analyst-inference", + environment: null, + limitations: + report.status === "pass" + ? [] + : [ + "Aggregate pass is withheld until every required conformance stage passes.", + ], + evidenceLinks: report.evidence_links, + }), + ); + } catch (cause: unknown) { + if (cause instanceof z.ZodError) + return err(new AnalysisInputError(OPERATION, { cause })); + return err( + new AnalysisProtocolError("Reconstruction readiness evaluation failed", { + cause, + }), + ); + } +}; diff --git a/src/artifacts/ArtifactProvider.ts b/src/artifacts/ArtifactProvider.ts index e0ef5729f..eefcf5851 100644 --- a/src/artifacts/ArtifactProvider.ts +++ b/src/artifacts/ArtifactProvider.ts @@ -11,6 +11,7 @@ import { inventoryArtifact } from "../application/ArtifactInventory.js"; import { extractArtifact } from "../application/ArtifactExtraction.js"; import { ARTIFACT_TOOL_CONTRACTS, + artifactInspectionInputSchema, artifactInventoryInputSchema, artifactExtractionInputSchema, type ArtifactToolName, @@ -28,6 +29,8 @@ import { type ArtifactLimits, } from "./ArtifactReader.js"; import { ARTIFACT_GRAPH_PROVIDER } from "../application/InvestigationProviders.js"; +import { createEvidence } from "../domain/evidence.js"; +import { createArtifactInspection } from "../domain/artifactInspection.js"; const IDENTITY: ProviderIdentity = Object.freeze(ARTIFACT_GRAPH_PROVIDER); @@ -47,7 +50,10 @@ export class ArtifactProvider implements AnalysisProvider { outputContractVersion: 1, available: true as const, reason: null, - pagination: "offset" as const, + pagination: + contract.name === "inspect_artifact" + ? ("none" as const) + : ("offset" as const), exhaustive: false, effects: Object.freeze({ mutatesArtifact: false, @@ -111,6 +117,10 @@ class ArtifactClient implements AnalysisClient { ), ); try { + if (operation === "inspect_artifact") { + const inspected = await this.inspectArtifact(parameters, options); + return inspected; + } if (operation === "extract_artifact") { const parsed = artifactExtractionInputSchema.parse(parameters); const result = await extractArtifact( @@ -143,31 +153,7 @@ class ArtifactClient implements AnalysisClient { ); } const parsed = artifactInventoryInputSchema.parse(parameters); - const result = await inventoryArtifact( - this.target.sourcePath ?? this.target.path, - limitsFrom(parsed), - { - nodeOffset: parsed.node_offset, - nodeLimit: parsed.node_limit, - occurrenceOffset: parsed.occurrence_offset, - occurrenceLimit: parsed.occurrence_limit, - edgeOffset: parsed.edge_offset, - edgeLimit: parsed.edge_limit, - }, - { - ...(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, - }, - }, - ); + const result = await this.inventory(parsed, options); return ok( createAnalysisExecution(result, IDENTITY, { rawResult: null, @@ -190,6 +176,119 @@ class ArtifactClient implements AnalysisClient { close(): Promise { return Promise.resolve(); } + + private async inspectArtifact( + parameters: Readonly>, + options?: ExecutionOptions, + ) { + const parsed = artifactInspectionInputSchema.parse(parameters); + const inventoryParameters = artifactInventoryInputSchema.parse({ + node_offset: 0, + node_limit: parsed.max_observations, + occurrence_offset: 0, + occurrence_limit: parsed.max_observations, + edge_offset: 0, + edge_limit: parsed.max_relationships, + native_mount_approved: parsed.native_mount_approved, + integrity_policy: parsed.integrity_policy, + integrity_continue_approved: parsed.integrity_continue_approved, + max_integrity_mismatches: parsed.max_integrity_mismatches, + max_entries: parsed.max_entries, + max_total_bytes: parsed.max_total_bytes, + max_entry_bytes: parsed.max_entry_bytes, + max_compression_ratio: parsed.max_compression_ratio, + max_depth: parsed.max_depth, + max_path_bytes: parsed.max_path_bytes, + }); + await options?.progress?.report({ + phase: "inspect_artifact.inventory", + completed: 0, + total: 1, + message: "inventory substep started", + }); + const inventory = await this.inventory(inventoryParameters, options); + const subject = subjectFor( + this.target.sourcePath ?? this.target.path, + inventory.manifest, + ); + const locations = inventory.occurrences.items.map( + ({ logical_path: path }) => ({ + kind: "artifact-path" as const, + path, + }), + ); + const inventoryEvidence = createEvidence(subject, IDENTITY, { + operation: "inventory_artifact", + parameters: inventoryParameters, + result: inventory, + rawResult: null, + limitations: inventory.limitations, + locations, + }); + const result = createArtifactInspection(inventoryEvidence, parsed); + await options?.progress?.report({ + phase: "inspect_artifact.inventory", + completed: 1, + total: 1, + message: "inventory substep completed", + }); + return ok( + createAnalysisExecution(result, IDENTITY, { + rawResult: null, + limitations: result.limitations, + subject, + locations, + }), + ); + } + + private inventory( + parsed: { + readonly node_offset: number; + readonly node_limit: number; + readonly occurrence_offset: number; + readonly occurrence_limit: number; + readonly edge_offset: number; + readonly edge_limit: number; + readonly native_mount_approved: boolean; + readonly integrity_policy: "fail" | "record-and-continue"; + readonly integrity_continue_approved: boolean; + readonly max_integrity_mismatches: number; + readonly max_entries: number; + readonly max_total_bytes: number; + readonly max_entry_bytes: number; + readonly max_compression_ratio: number; + readonly max_depth: number; + readonly max_path_bytes: number; + }, + options?: ExecutionOptions, + ) { + return inventoryArtifact( + this.target.sourcePath ?? this.target.path, + limitsFrom(parsed), + { + nodeOffset: parsed.node_offset, + nodeLimit: parsed.node_limit, + occurrenceOffset: parsed.occurrence_offset, + occurrenceLimit: parsed.occurrence_limit, + edgeOffset: parsed.edge_offset, + edgeLimit: parsed.edge_limit, + }, + { + ...(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, + }, + }, + ); + } } const limitsFrom = (input: { diff --git a/src/browser/BrowserScenarioSecrets.ts b/src/browser/BrowserScenarioSecrets.ts new file mode 100644 index 000000000..bbe07e69b --- /dev/null +++ b/src/browser/BrowserScenarioSecrets.ts @@ -0,0 +1,70 @@ +import { createHash } from "node:crypto"; + +import type { + BrowserScenario, + BrowserScenarioUrl, + BrowserScenarioValue, +} from "../domain/browserScenario.js"; + +const REDACTION_PREFIX = "[REDACTED:"; + +/** Resolved secret values kept only for one in-memory scenario session. */ +export class BrowserScenarioSecrets { + private constructor(private readonly values: ReadonlyMap) {} + + static resolve( + scenario: BrowserScenario, + environment: Readonly>, + ): BrowserScenarioSecrets | undefined { + const values = new Map(); + for (const declaration of scenario.secrets) { + const value = environment[declaration.environment_variable]; + if (value === undefined) return undefined; + values.set(declaration.secret_id, value); + } + return new BrowserScenarioSecrets(values); + } + + value(source: BrowserScenarioValue): string { + if (source.source === "literal") return source.value; + const value = this.values.get(source.secret_id); + if (value === undefined) + throw new Error("Validated browser secret was not resolved"); + return value; + } + + url(destination: BrowserScenarioUrl): string { + const url = new URL(destination.url); + for (const { name, value } of destination.query) + url.searchParams.append(name, this.value(value)); + return url.href; + } + + redact(value: string): string { + let output = value; + const replacements = [...this.values].sort( + ([leftId, left], [rightId, right]) => + right.length - left.length || + (leftId < rightId ? -1 : leftId > rightId ? 1 : 0), + ); + for (const [id, secret] of replacements) + if (secret !== "") + output = output.replaceAll(secret, `${REDACTION_PREFIX}${id}]`); + return output; + } + + fingerprint(value: string): { + readonly value_state: "hashed" | "redacted-secret"; + readonly value_sha256: string | null; + } { + for (const secret of this.values.values()) + if (secret !== "" && value.includes(secret)) + return { value_state: "redacted-secret", value_sha256: null }; + return { + value_state: "hashed", + value_sha256: createHash("sha256") + .update(this.redact(value)) + .digest("hex"), + }; + } +} diff --git a/src/browser/BrowserScenarioSessionPort.ts b/src/browser/BrowserScenarioSessionPort.ts new file mode 100644 index 000000000..bec40bb43 --- /dev/null +++ b/src/browser/BrowserScenarioSessionPort.ts @@ -0,0 +1,59 @@ +import type { + BrowserScenario, + BrowserScenarioAction, +} from "../domain/browserScenario.js"; +import type { + BrowserScenarioEvent, + BrowserStepArtifacts, +} from "../domain/browserScenarioCapture.js"; +import type { BrowserScenarioCaptureBudget } from "./PlaywrightScenarioArtifacts.js"; + +type SnapshotKind = BrowserScenario["capture"]["after_each_step"][number]; + +export interface BrowserScenarioSessionPort { + readonly mode: "launch" | "connect"; + readonly processOwnership: "provider-owned" | "external"; + readonly product: string; + readonly version: string; + readonly initialUrl: string; + currentUrl(): string; + setStep(index: number): void; + nextEventSequence(): number; + lastEventSequence(): number; + events(): { + readonly retained: number; + readonly dropped: number; + readonly items: readonly BrowserScenarioEvent[]; + }; + eventTruncationSections(): readonly ( + | "events" + | "frames" + | "workers" + | "popups" + | "websockets" + )[]; + perform( + action: BrowserScenarioAction, + maximumTimeoutMs: number, + signal?: AbortSignal, + ): Promise; + capture( + requested: ReadonlySet, + budget: BrowserScenarioCaptureBudget, + maximumTimeoutMs: number, + signal?: AbortSignal, + ): Promise; + close(): Promise<"terminated-owned-process" | "disconnected-external">; + redactError(error: unknown): string; +} + +export interface BrowserScenarioSessionFactory { + open( + scenario: BrowserScenario, + options?: { + readonly signal?: AbortSignal; + readonly budget?: BrowserScenarioCaptureBudget; + readonly deadlineAt?: number; + }, + ): Promise; +} diff --git a/src/browser/CdpBrowserProvider.ts b/src/browser/CdpBrowserProvider.ts index e5fcda42e..5c2356cf1 100644 --- a/src/browser/CdpBrowserProvider.ts +++ b/src/browser/CdpBrowserProvider.ts @@ -27,11 +27,11 @@ import { type WebMcpDiscovery, } from "../domain/webMcpDiscovery.js"; import { - compareWebCaptures, - webCaptureDiffSchema, - type CompareWebCapturesInput, - type WebCaptureDiff, -} from "../domain/webCaptureDiff.js"; + browserCaptureComparisonSchema, + compareBrowserCaptures, + type BrowserCaptureComparison, + type BrowserCaptureComparisonInput, +} from "../domain/browserCaptureComparison.js"; import { webScreenshotDiffSchema, webScreenshotSchema, @@ -248,10 +248,12 @@ export class CdpBrowserProvider implements BrowserObservationPort { } async compareCaptures( - input: CompareWebCapturesInput, - ): Promise> { + input: BrowserCaptureComparisonInput, + ): Promise> { try { - return ok(webCaptureDiffSchema.parse(compareWebCaptures(input))); + return ok( + browserCaptureComparisonSchema.parse(compareBrowserCaptures(input)), + ); } catch (cause: unknown) { return err(providerError(cause, "compare_web_captures")); } diff --git a/src/browser/CdpCaptureDocuments.ts b/src/browser/CdpCaptureDocuments.ts index 3f371a467..ea0e4bc48 100644 --- a/src/browser/CdpCaptureDocuments.ts +++ b/src/browser/CdpCaptureDocuments.ts @@ -232,10 +232,17 @@ export const captureAccessibility = ( readonly total: number; readonly nodes: WebPageInspection["accessibility"]["nodes"]; readonly textCapture: WebPageInspection["accessibility"]["text_capture"]; + readonly treeIncomplete: boolean; } => { const all = results.flatMap((result) => recordsValue(requiredRecord(result).nodes), ); + const nodeIds = new Set( + all.flatMap((node) => { + const nodeId = stringValue(node.nodeId); + return nodeId === undefined ? [] : [nodeId]; + }), + ); let retainedBytes = 0; let excludedFields = 0; let truncatedFields = 0; @@ -269,6 +276,7 @@ export const captureAccessibility = ( name: captureText(node.name), description: captureText(node.description), ignored: node.ignored === true, + states: accessibilityStates(node.properties), }; }); for (const node of all.slice(maximum)) { @@ -281,6 +289,14 @@ export const captureAccessibility = ( return { total: all.length, nodes, + treeIncomplete: all.some( + (node) => + Array.isArray(node.childIds) && + node.childIds.some((childId) => { + const id = stringValue(childId); + return id !== undefined && !nodeIds.has(id); + }), + ), textCapture: { status: options.unavailable ? "unavailable" @@ -296,6 +312,56 @@ export const captureAccessibility = ( }; }; +const ACCESSIBILITY_STATE_NAMES = new Set([ + "atomic", + "autocomplete", + "busy", + "checked", + "disabled", + "editable", + "expanded", + "focusable", + "focused", + "hasPopup", + "hidden", + "invalid", + "level", + "live", + "modal", + "multiline", + "multiselectable", + "orientation", + "pressed", + "readonly", + "required", + "selected", + "valuemax", + "valuemin", +]); + +const accessibilityStates = ( + value: unknown, +): WebPageInspection["accessibility"]["nodes"][number]["states"] => { + const states = new Map(); + for (const property of recordsValue(value)) { + const name = stringValue(property.name); + if (name === undefined || !ACCESSIBILITY_STATE_NAMES.has(name)) continue; + const raw = recordValue(property.value)?.value; + const state = + typeof raw === "boolean" + ? raw + : typeof raw === "number" + ? numberValue(raw) + : typeof raw === "string" + ? raw.slice(0, 256) + : undefined; + if (state !== undefined) states.set(name, state); + } + return [...states] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, state]) => ({ name, value: state })); +}; + const axText = (value: unknown): string | null => boundedText(recordValue(value)?.value, 1_024); diff --git a/src/browser/CdpCaptureStorage.ts b/src/browser/CdpCaptureStorage.ts index 00463d07d..d253035c2 100644 --- a/src/browser/CdpCaptureStorage.ts +++ b/src/browser/CdpCaptureStorage.ts @@ -4,6 +4,11 @@ import type { } from "../domain/browserObservation.js"; import { CdpConnection } from "./CdpConnection.js"; import { optionalCdpCommand } from "./CdpOptionalCommand.js"; +import { + captureStorageFingerprints, + type CapturedCache, + type CapturedStorageItems, +} from "./CdpCaptureStorageFingerprints.js"; import { numberValue, recordValue, @@ -34,83 +39,122 @@ export const captureStorage = async ( ), ); const local = context.input.include_storage_keys - ? await storageKeys(context, origin, true, limitations) - : []; + ? await storageItems(context, origin, true, limitations) + : emptyStorageItems; const session = context.input.include_storage_keys - ? await storageKeys(context, origin, false, limitations) - : []; - const indexed = context.input.include_storage_keys - ? stringArray( - recordValue( - await optionalCdpCommand( - context, - "IndexedDB.requestDatabaseNames", - { securityOrigin: origin }, - limitations, - ), - )?.databaseNames, + ? await storageItems(context, origin, false, limitations) + : emptyStorageItems; + const indexedRaw = context.input.include_storage_keys + ? await optionalCdpCommand( + context, + "IndexedDB.requestDatabaseNames", + { securityOrigin: origin }, + limitations, ) - : []; - const caches = context.input.include_storage_keys - ? recordsValue( - recordValue( - await optionalCdpCommand( - context, - "CacheStorage.requestCacheNames", - { securityOrigin: origin }, - limitations, - ), - )?.caches, - ).flatMap((cache) => { - const name = stringValue(cache.cacheName); - return name === undefined ? [] : [name.slice(0, 1_024)]; - }) - : []; + : undefined; + const indexedValues = recordValue(indexedRaw)?.databaseNames; + const indexed = stringArray(indexedValues); + const cacheRaw = context.input.include_storage_keys + ? await optionalCdpCommand( + context, + "CacheStorage.requestCacheNames", + { securityOrigin: origin }, + limitations, + ) + : undefined; + const cacheValues = recordsValue(recordValue(cacheRaw)?.caches); + const caches = capturedCaches(cacheValues); const maximum = context.input.limits.max_storage_keys; + const fingerprints = context.input.include_storage_fingerprints + ? await captureStorageFingerprints({ + context, + origin, + local, + session, + indexedDbNames: indexed.slice(0, maximum), + caches: caches.slice(0, maximum), + limitations, + }) + : { items: [], complete: false, truncated: false }; + const truncated = + [local.items, session.items, indexed, caches].some( + (items) => items.length > maximum, + ) || fingerprints.truncated; + const structuredStorageComplete = + indexedRaw !== undefined && + Array.isArray(indexedValues) && + indexed.length === indexedValues.length && + cacheRaw !== undefined && + caches.length === cacheValues.length; return { value: { origin, usage_bytes: numberValue(quota?.usage) ?? null, quota_bytes: numberValue(quota?.quota) ?? null, - local_storage_keys: local.slice(0, maximum), - session_storage_keys: session.slice(0, maximum), - indexed_db_names: indexed.slice(0, maximum), - cache_names: caches.slice(0, maximum), + local_storage_keys: local.items + .map(({ key }) => key.slice(0, 1_024)) + .slice(0, maximum), + session_storage_keys: session.items + .map(({ key }) => key.slice(0, 1_024)) + .slice(0, maximum), + indexed_db_names: indexed + .map((name) => name.slice(0, 1_024)) + .slice(0, maximum), + cache_names: caches + .map(({ name }) => name.slice(0, 1_024)) + .slice(0, maximum), + content_fingerprints: [...fingerprints.items], + fingerprint_algorithm: "sha256", + fingerprints_complete: + fingerprints.complete && structuredStorageComplete && !truncated, values_redacted: true, }, - truncated: [local, session, indexed, caches].some( - (items) => items.length > maximum, - ), + truncated, }; }; -const storageKeys = async ( +const emptyStorageItems: CapturedStorageItems = { + items: [], + complete: false, +}; + +const capturedCaches = ( + values: readonly Record[], +): CapturedCache[] => + values.flatMap((cache) => { + const name = stringValue(cache.cacheName); + const id = stringValue(cache.cacheId); + return name === undefined || id === undefined ? [] : [{ name, id }]; + }); + +const storageItems = async ( context: Parameters[0], origin: string, isLocalStorage: boolean, limitations: string[], -): Promise => { - const result = recordValue( - await optionalCdpCommand( - context, - "DOMStorage.getDOMStorageItems", - { storageId: { securityOrigin: origin, isLocalStorage } }, - limitations, - ), +): Promise => { + const raw = await optionalCdpCommand( + context, + "DOMStorage.getDOMStorageItems", + { storageId: { securityOrigin: origin, isLocalStorage } }, + limitations, ); - return Array.isArray(result?.entries) - ? result.entries.flatMap((entry) => { - if (!Array.isArray(entry)) return []; - const key = stringValue(entry[0]); - return key === undefined ? [] : [key.slice(0, 1_024)]; - }) - : []; + const entries = recordValue(raw)?.entries; + if (raw === undefined || !Array.isArray(entries)) + return { items: [], complete: false }; + const items = entries.flatMap((entry) => { + if (!Array.isArray(entry)) return []; + const key = stringValue(entry[0]); + const value = stringValue(entry[1]); + return key === undefined || value === undefined ? [] : [{ key, value }]; + }); + return { items, complete: items.length === entries.length }; }; const stringArray = (value: unknown): readonly string[] => Array.isArray(value) ? value.flatMap((item) => { const text = stringValue(item); - return text === undefined ? [] : [text.slice(0, 1_024)]; + return text === undefined ? [] : [text]; }) : []; diff --git a/src/browser/CdpCaptureStorageFingerprints.ts b/src/browser/CdpCaptureStorageFingerprints.ts new file mode 100644 index 000000000..b2cb1591c --- /dev/null +++ b/src/browser/CdpCaptureStorageFingerprints.ts @@ -0,0 +1,501 @@ +import { createHash } from "node:crypto"; + +import canonicalize from "canonicalize"; + +import type { + InspectWebPageInput, + WebPageInspection, +} from "../domain/browserObservation.js"; +import { CdpConnection } from "./CdpConnection.js"; +import { optionalCdpCommand } from "./CdpOptionalCommand.js"; +import { recordValue, recordsValue, stringValue } from "./CdpCaptureValues.js"; + +const MAX_CACHE_BODY_BYTES = 64 * 1_024; + +type StorageFingerprint = + WebPageInspection["storage"]["content_fingerprints"][number]; + +export interface CapturedStorageItems { + readonly items: readonly { + readonly key: string; + readonly value: string; + }[]; + readonly complete: boolean; +} + +export interface CapturedCache { + readonly name: string; + readonly id: string; +} + +interface StorageFingerprintContext { + readonly connection: CdpConnection; + readonly sessionId: string | undefined; + readonly input: InspectWebPageInput; + readonly signal?: AbortSignal; +} + +interface FingerprintState { + readonly items: StorageFingerprint[]; + readonly maximum: number; + complete: boolean; + truncated: boolean; +} + +interface FingerprintRun { + readonly context: StorageFingerprintContext; + readonly limitations: string[]; + readonly state: FingerprintState; +} + +interface CaptureStorageFingerprintInput { + readonly context: StorageFingerprintContext; + readonly origin: string; + readonly local: CapturedStorageItems; + readonly session: CapturedStorageItems; + readonly indexedDbNames: readonly string[]; + readonly caches: readonly CapturedCache[]; + readonly limitations: string[]; +} + +/** Capture stable hashes of approved storage content without returning values. */ +export const captureStorageFingerprints = async ({ + context, + origin, + local, + session, + indexedDbNames, + caches, + limitations, +}: CaptureStorageFingerprintInput): Promise<{ + readonly items: readonly StorageFingerprint[]; + readonly complete: boolean; + readonly truncated: boolean; +}> => { + const state: FingerprintState = { + items: [], + maximum: context.input.limits.max_storage_keys, + complete: local.complete && session.complete, + truncated: false, + }; + const run = { context, limitations, state }; + addStorageItems(state, "local_storage", local.items); + addStorageItems(state, "session_storage", session.items); + await addCookies(run, origin); + await addIndexedDb(run, origin, indexedDbNames); + await addCaches(run, caches); + state.items.sort((left, right) => + compareCodePoints( + `${left.scope}:${left.identity_sha256}`, + `${right.scope}:${right.identity_sha256}`, + ), + ); + return { + items: state.items, + complete: + state.complete && + !state.truncated && + state.items.every(({ complete }) => complete), + truncated: state.truncated, + }; +}; + +const addStorageItems = ( + state: FingerprintState, + scope: "local_storage" | "session_storage", + items: CapturedStorageItems["items"], +): void => { + for (const item of items) + addFingerprint(state, { + scope, + identity: item.key, + value: item.value, + complete: true, + }); +}; + +const addCookies = async ( + run: FingerprintRun, + origin: string, +): Promise => { + const raw = await optionalCdpCommand( + run.context, + "Network.getCookies", + { urls: [origin] }, + run.limitations, + ); + if (raw === undefined) { + run.state.complete = false; + return; + } + const cookieValues = recordValue(raw)?.cookies; + if (!Array.isArray(cookieValues)) { + run.state.complete = false; + return; + } + const cookies = recordsValue(cookieValues); + if (cookies.length !== cookieValues.length) run.state.complete = false; + for (const cookie of cookies) { + const name = stringValue(cookie.name); + const value = stringValue(cookie.value); + const domain = stringValue(cookie.domain); + const path = stringValue(cookie.path); + if ( + name === undefined || + value === undefined || + domain === undefined || + path === undefined + ) { + run.state.complete = false; + continue; + } + addFingerprint(run.state, { + scope: "cookie", + identity: { + domain, + path, + name, + partition_key: cookie.partitionKey ?? null, + }, + value, + complete: true, + }); + } +}; + +const addIndexedDb = async ( + run: FingerprintRun, + origin: string, + names: readonly string[], +): Promise => { + for (const databaseName of names) { + if (run.state.items.length >= run.state.maximum) { + run.state.truncated = true; + return; + } + const raw = await optionalCdpCommand( + run.context, + "IndexedDB.requestDatabase", + { securityOrigin: origin, databaseName }, + run.limitations, + ); + const database = recordValue(recordValue(raw)?.databaseWithObjectStores); + if (raw === undefined || database === undefined) { + run.state.complete = false; + continue; + } + if (stringValue(database.name) !== databaseName) { + run.state.complete = false; + continue; + } + if (!Array.isArray(database.objectStores)) { + run.state.complete = false; + continue; + } + const stores = recordsValue(database.objectStores); + if (stores.length !== database.objectStores.length) + run.state.complete = false; + addFingerprint(run.state, { + scope: "indexed_db_schema", + identity: databaseName, + value: stores.map(indexedDbStoreSchema), + complete: indexedDbSchemaComplete(stores), + }); + for (const store of stores) { + const objectStoreName = stringValue(store.name); + if (objectStoreName === undefined) { + run.state.complete = false; + continue; + } + await addIndexedDbRecords(run, { + origin, + databaseName, + objectStoreName, + }); + } + } +}; + +const indexedDbStoreSchema = (store: Readonly>) => ({ + name: stringValue(store.name) ?? null, + key_path: stableValue(store.keyPath), + auto_increment: store.autoIncrement === true, + indexes: recordsValue(store.indexes).map((index) => ({ + name: stringValue(index.name) ?? null, + key_path: stableValue(index.keyPath), + unique: index.unique === true, + multi_entry: index.multiEntry === true, + })), +}); + +const indexedDbSchemaComplete = ( + stores: readonly Readonly>[], +): boolean => + stores.every( + (store) => + stringValue(store.name) !== undefined && + stableValueComplete(store.keyPath) && + typeof store.autoIncrement === "boolean" && + Array.isArray(store.indexes) && + recordsValue(store.indexes).length === store.indexes.length && + recordsValue(store.indexes).every( + (index) => + stringValue(index.name) !== undefined && + stableValueComplete(index.keyPath) && + typeof index.unique === "boolean" && + typeof index.multiEntry === "boolean", + ), + ); + +const addIndexedDbRecords = async ( + run: FingerprintRun, + input: { + readonly origin: string; + readonly databaseName: string; + readonly objectStoreName: string; + }, +): Promise => { + const remaining = run.state.maximum - run.state.items.length; + if (remaining <= 0) { + run.state.truncated = true; + return; + } + const raw = await optionalCdpCommand( + run.context, + "IndexedDB.requestData", + { + securityOrigin: input.origin, + databaseName: input.databaseName, + objectStoreName: input.objectStoreName, + indexName: "", + skipCount: 0, + pageSize: remaining, + }, + run.limitations, + ); + const result = recordValue(raw); + if (raw === undefined || result === undefined) { + run.state.complete = false; + return; + } + if ( + !Array.isArray(result.objectStoreDataEntries) || + typeof result.hasMore !== "boolean" + ) { + run.state.complete = false; + return; + } + const entries = recordsValue(result.objectStoreDataEntries); + if (entries.length !== result.objectStoreDataEntries.length) + run.state.complete = false; + for (const entry of entries) { + const key = stableValue(entry.key); + const primaryKey = stableValue(entry.primaryKey); + const value = stableValue(entry.value); + addFingerprint(run.state, { + scope: "indexed_db_record", + identity: { + database: input.databaseName, + store: input.objectStoreName, + key, + primaryKey, + }, + value, + complete: remoteObjectComplete(entry.value), + }); + } + if (result.hasMore === true || entries.length > remaining) + run.state.truncated = true; +}; + +const addCaches = async ( + run: FingerprintRun, + caches: readonly CapturedCache[], +): Promise => { + for (const cache of caches) { + const remaining = run.state.maximum - run.state.items.length; + if (remaining <= 0) { + run.state.truncated = true; + return; + } + const raw = await optionalCdpCommand( + run.context, + "CacheStorage.requestEntries", + { cacheId: cache.id, skipCount: 0, pageSize: remaining }, + run.limitations, + ); + const result = recordValue(raw); + if (raw === undefined || result === undefined) { + run.state.complete = false; + continue; + } + if ( + !Array.isArray(result.cacheDataEntries) || + typeof result.returnCount !== "number" + ) { + run.state.complete = false; + continue; + } + const entries = recordsValue(result.cacheDataEntries); + if (entries.length !== result.cacheDataEntries.length) + run.state.complete = false; + for (const entry of entries) await addCacheEntry(run, cache, entry); + if (result.returnCount > entries.length) run.state.truncated = true; + } +}; + +const addCacheEntry = async ( + run: FingerprintRun, + cache: CapturedCache, + entry: Readonly>, +): Promise => { + const requestURL = stringValue(entry.requestURL); + const requestMethod = stringValue(entry.requestMethod); + if (requestURL === undefined || requestMethod === undefined) { + run.state.complete = false; + return; + } + if (!Array.isArray(entry.requestHeaders)) { + run.state.complete = false; + return; + } + const headerRecords = recordsValue(entry.requestHeaders); + const requestHeaders = headerRecords.flatMap((header) => { + const name = stringValue(header.name); + const value = stringValue(header.value); + return name === undefined || value === undefined ? [] : [{ name, value }]; + }); + if ( + headerRecords.length !== entry.requestHeaders.length || + requestHeaders.length !== headerRecords.length + ) { + run.state.complete = false; + return; + } + const raw = await optionalCdpCommand( + run.context, + "CacheStorage.requestCachedResponse", + { cacheId: cache.id, requestURL, requestHeaders }, + run.limitations, + ); + const body = stringValue(recordValue(recordValue(raw)?.response)?.body); + const decoded = body === undefined ? null : decodeBase64(body); + const complete = + decoded !== null && decoded.byteLength <= MAX_CACHE_BODY_BYTES; + if (!complete) run.state.complete = false; + addFingerprint(run.state, { + scope: "cache_entry", + identity: { cache: cache.name, requestURL, requestMethod, requestHeaders }, + value: decoded ?? undefined, + complete, + }); +}; + +const addFingerprint = ( + state: FingerprintState, + input: { + readonly scope: StorageFingerprint["scope"]; + readonly identity: unknown; + readonly value: unknown; + readonly complete: boolean; + }, +): void => { + if (state.items.length >= state.maximum) { + state.truncated = true; + return; + } + state.items.push({ + scope: input.scope, + identity_sha256: digest(input.identity), + value_sha256: input.value === undefined ? null : digest(input.value), + complete: + input.complete && + stableValueComplete(input.identity) && + stableValueComplete(input.value), + }); +}; + +const digest = (value: unknown): string => + createHash("sha256").update(encodedValue(value)).digest("hex"); + +const encodedValue = (value: unknown): Uint8Array | string => { + if (value instanceof Uint8Array) return value; + const encoded = canonicalize(stableValue(value)); + if (encoded === undefined) throw new TypeError("Expected canonical JSON"); + return encoded; +}; + +const stableValue = (value: unknown, depth = 0): unknown => { + if ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) + return value; + if (depth >= 8) return ""; + if (Array.isArray(value)) + return value.slice(0, 64).map((item) => stableValue(item, depth + 1)); + const record = recordValue(value); + if (record === undefined) return String(value); + return Object.fromEntries( + Object.entries(record) + .filter(([key]) => key !== "objectId" && key !== "customPreview") + .sort(([left], [right]) => compareCodePoints(left, right)) + .slice(0, 64) + .map(([key, item]) => [key, stableValue(item, depth + 1)]), + ); +}; + +const stableValueComplete = (value: unknown, depth = 0): boolean => { + if (value instanceof Uint8Array) return true; + if ( + value === null || + value === undefined || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) + return true; + if (depth >= 8) return false; + if (Array.isArray(value)) + return ( + value.length <= 64 && + value.every((item) => stableValueComplete(item, depth + 1)) + ); + const record = recordValue(value); + if (record === undefined) return false; + const entries = Object.entries(record).filter( + ([key]) => key !== "objectId" && key !== "customPreview", + ); + return ( + entries.length <= 64 && + entries.every(([, item]) => stableValueComplete(item, depth + 1)) + ); +}; + +const remoteObjectComplete = (value: unknown): boolean => { + const record = recordValue(value); + if (record === undefined) return true; + return ( + record.objectId === undefined && + record.preview === undefined && + record.customPreview === undefined && + (record.type !== "object" || record.value !== undefined) && + stableValueComplete(record) + ); +}; + +const decodeBase64 = (value: string): Buffer | null => { + if ( + value.length % 4 !== 0 || + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test( + value, + ) + ) + return null; + return Buffer.from(value, "base64"); +}; + +const compareCodePoints = (left: string, right: string): number => + left < right ? -1 : left > right ? 1 : 0; diff --git a/src/browser/CdpEndpoint.ts b/src/browser/CdpEndpoint.ts index 4c9d61739..df810721c 100644 --- a/src/browser/CdpEndpoint.ts +++ b/src/browser/CdpEndpoint.ts @@ -74,24 +74,24 @@ export const discoverCdpEndpoint = async ( operation: BrowserObservationOperation, signal?: AbortSignal, ): Promise => { - const versionInput = await readJson( + const versionInput = await readBoundedCdpJson( new URL("/json/version", endpoint), MAX_VERSION_BYTES, operation, signal, ); - const targetsInput = await readJson( + const targetsInput = await readBoundedCdpJson( new URL("/json/list", endpoint), MAX_TARGET_LIST_BYTES, operation, signal, ); - const version = parseEndpointValue( + const version = parseCdpEndpointValue( endpointVersionSchema, versionInput, operation, ); - const targets = parseEndpointValue( + const targets = parseCdpEndpointValue( endpointTargetsSchema, targetsInput, operation, @@ -199,7 +199,8 @@ const availableCdpTargetWebSocket = ( : undefined; }; -const parseEndpointValue = ( +/** Parse one CDP discovery value through its exact boundary schema. */ +export const parseCdpEndpointValue = ( schema: z.ZodType, input: unknown, operation: BrowserObservationOperation, @@ -289,7 +290,8 @@ const targetWebSocket = ( } }; -const readJson = async ( +/** Read one bounded loopback CDP discovery document without redirects. */ +export const readBoundedCdpJson = async ( url: URL, maximumBytes: number, operation: BrowserObservationOperation, diff --git a/src/browser/CdpPageCapture.ts b/src/browser/CdpPageCapture.ts index ad98f2a88..6c3809b9e 100644 --- a/src/browser/CdpPageCapture.ts +++ b/src/browser/CdpPageCapture.ts @@ -386,6 +386,7 @@ const accessibilityForFrames = async ( ); if (capture.textCapture.status === "truncated") events.completeness.truncate("accessibility"); + if (capture.treeIncomplete) events.completeness.truncate("accessibility"); return capture; }; diff --git a/src/browser/JavaScriptRuntimeScope.ts b/src/browser/JavaScriptRuntimeScope.ts new file mode 100644 index 000000000..47c609e4c --- /dev/null +++ b/src/browser/JavaScriptRuntimeScope.ts @@ -0,0 +1,60 @@ +import { + browserOriginSchema, + sanitizeBrowserUrl, +} from "../domain/browserObservation.js"; +import type { JavaScriptRuntimeLocation } from "../domain/javascriptRuntimeObservation.js"; +import { + authorizedElectronFile, + canonicalElectronRoots, +} from "./ElectronFileScope.js"; + +export type RuntimeLocationDecision = + | { readonly allowed: true; readonly location: JavaScriptRuntimeLocation } + | { + readonly allowed: false; + readonly reason: + | "outside_file_roots" + | "outside_origins" + | "unsupported_location"; + }; + +/** Canonicalize exact filesystem scopes before Inspector attachment. */ +export const canonicalRuntimeRoots = canonicalElectronRoots; + +/** Authorize one protocol URL without retaining an out-of-scope value. */ +export const authorizeRuntimeLocation = async ( + value: string, + roots: readonly string[], + origins: readonly string[], +): Promise => { + if (value.startsWith("node:") && value.length <= 4_096) + return { + allowed: true, + location: { kind: "builtin", specifier: value }, + }; + if (value.startsWith("file:")) { + const filePath = await authorizedElectronFile(value, roots); + return filePath === undefined + ? { allowed: false, reason: "outside_file_roots" } + : { allowed: true, location: { kind: "file", file_path: filePath } }; + } + let url: URL; + try { + url = new URL(value); + } catch { + return { allowed: false, reason: "unsupported_location" }; + } + if (!["http:", "https:"].includes(url.protocol)) + return { allowed: false, reason: "unsupported_location" }; + const parsedOrigin = browserOriginSchema.safeParse(url.origin); + if (!parsedOrigin.success || !origins.includes(parsedOrigin.data)) + return { allowed: false, reason: "outside_origins" }; + return { + allowed: true, + location: { + kind: "url", + origin: parsedOrigin.data, + sanitized_url: sanitizeBrowserUrl(value).url, + }, + }; +}; diff --git a/src/browser/PlaywrightBrowserScenarioProvider.ts b/src/browser/PlaywrightBrowserScenarioProvider.ts new file mode 100644 index 000000000..66924bec7 --- /dev/null +++ b/src/browser/PlaywrightBrowserScenarioProvider.ts @@ -0,0 +1,380 @@ +import type { BrowserScenarioCapturePort } from "../application/BrowserScenarioCapturePort.js"; +import type { + ExecutionOptions, + ProviderIdentity, +} from "../application/AnalysisProvider.js"; +import { sanitizeBrowserUrl } from "../domain/browserObservation.js"; +import type { + BrowserScenario, + BrowserScenarioAction, +} from "../domain/browserScenario.js"; +import { + browserScenarioCaptureSchema, + browserScenarioCompletenessSchema, + browserScenarioStepSchema, + browserStepArtifactsSchema, + type BrowserScenarioCapture, + type BrowserScenarioCompleteness, + type BrowserScenarioStep, + type BrowserStepArtifacts, +} from "../domain/browserScenarioCapture.js"; +import { + AnalysisError, + BrowserObservationError, + ProviderAdapterError, +} from "../domain/errors.js"; +import { err, ok, type Result } from "../domain/result.js"; +import type { + BrowserScenarioSessionFactory, + BrowserScenarioSessionPort, +} from "./BrowserScenarioSessionPort.js"; +import { BrowserScenarioCaptureBudget } from "./PlaywrightScenarioArtifacts.js"; +import { PlaywrightScenarioSessionFactory } from "./PlaywrightScenarioSession.js"; + +const OPERATION = "capture_browser_scenario" as const; + +export const PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY: ProviderIdentity = + Object.freeze({ + id: "rea-playwright-browser-scenario", + name: "REA Playwright browser scenario capture provider", + version: "1", + }); + +type SnapshotKind = BrowserScenario["capture"]["after_each_step"][number]; + +const requestedForStep = ( + scenario: BrowserScenario, + stepIndex: number, +): ReadonlySet => + new Set([ + ...scenario.capture.after_each_step, + ...(stepIndex === scenario.actions.length ? scenario.capture.at_end : []), + ]); + +const unavailableArtifacts = ( + requested: ReadonlySet, + reason: string, +): BrowserStepArtifacts => { + const state = (kind: SnapshotKind) => + requested.has(kind) + ? { state: "missing" as const, reason } + : { state: "not_requested" as const }; + return browserStepArtifactsSchema.parse({ + screenshot: state("screenshot"), + dom: state("dom"), + accessibility: state("accessibility"), + url: state("url"), + history: state("history"), + storage: state("storage"), + }); +}; + +const stepCompleteness = ( + artifacts: BrowserStepArtifacts, + status: BrowserScenarioStep["status"], +): BrowserScenarioCompleteness => { + const missing = Object.entries(artifacts) + .filter(([, state]) => state.state === "missing") + .map(([section]) => section); + const truncated = Object.entries(artifacts) + .filter(([, state]) => state.state === "truncated") + .map(([section]) => section); + if (status !== "completed") missing.push("action"); + const condition = + truncated.length > 0 + ? "truncated" + : missing.length > 0 + ? "incomplete" + : "complete"; + return browserScenarioCompletenessSchema.parse({ + status: condition, + equality_eligible: condition === "complete", + missing_sections: [...new Set(missing)].sort(), + truncated_sections: [...new Set(truncated)].sort(), + }); +}; + +const createStep = (input: { + readonly stepIndex: number; + readonly stepId: string; + readonly action: string; + readonly status: BrowserScenarioStep["status"]; + readonly elapsedMs: number; + readonly beforeUrl: string; + readonly afterUrl: string; + readonly error: string | null; + readonly eventStart: number; + readonly eventEnd: number; + readonly artifacts: BrowserStepArtifacts; +}): BrowserScenarioStep => + browserScenarioStepSchema.parse({ + step_index: input.stepIndex, + step_id: input.stepId, + action: input.action, + status: input.status, + elapsed_ms: input.elapsedMs, + before_url: sanitizeBrowserUrl(input.beforeUrl), + after_url: sanitizeBrowserUrl(input.afterUrl), + error: input.error, + event_sequence_start: input.eventStart, + event_sequence_end: input.eventEnd, + artifacts: input.artifacts, + completeness: stepCompleteness(input.artifacts, input.status), + }); + +const initialStep = async (input: { + readonly session: BrowserScenarioSessionPort; + readonly scenario: BrowserScenario; + readonly budget: BrowserScenarioCaptureBudget; + readonly elapsedMs: number; + readonly maximumTimeoutMs: number; + readonly signal: AbortSignal | undefined; +}): Promise => { + const { session, scenario, budget, elapsedMs, maximumTimeoutMs, signal } = + input; + const artifacts = await session.capture( + requestedForStep(scenario, 0), + budget, + maximumTimeoutMs, + signal, + ); + return createStep({ + stepIndex: 0, + stepId: "scenario_start", + action: "goto_start", + status: "completed", + elapsedMs, + beforeUrl: session.initialUrl, + afterUrl: session.currentUrl(), + error: null, + eventStart: 1, + eventEnd: session.lastEventSequence(), + artifacts, + }); +}; + +const executeStep = async (input: { + readonly session: BrowserScenarioSessionPort; + readonly scenario: BrowserScenario; + readonly budget: BrowserScenarioCaptureBudget; + readonly action: BrowserScenarioAction; + readonly stepIndex: number; + readonly startedAt: number; + readonly priorFailure: boolean; + readonly signal: AbortSignal | undefined; +}): Promise => { + const { + session, + scenario, + budget, + action, + stepIndex, + startedAt, + priorFailure, + signal, + } = input; + const beforeUrl = session.currentUrl(); + const eventStart = session.nextEventSequence(); + const requested = requestedForStep(scenario, stepIndex); + if (priorFailure) + return createStep({ + stepIndex, + stepId: action.step_id, + action: action.action, + status: "cancelled", + elapsedMs: 0, + beforeUrl, + afterUrl: beforeUrl, + error: "not executed after an earlier action failure", + eventStart, + eventEnd: session.lastEventSequence(), + artifacts: unavailableArtifacts( + requested, + "action was not executed after an earlier failure", + ), + }); + + const actionStartedAt = Date.now(); + session.setStep(stepIndex); + const requestCancelled = (): boolean => signal?.aborted === true; + let status: BrowserScenarioStep["status"] = "completed"; + let error: string | null = null; + if (requestCancelled()) { + status = "cancelled"; + error = "scenario request cancelled"; + } else { + try { + const elapsed = Date.now() - startedAt; + 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) { + status = requestCancelled() ? "cancelled" : "failed"; + error = session.redactError(cause); + } + } + const remaining = scenario.limits.max_duration_ms - (Date.now() - startedAt); + const artifacts = + status === "cancelled" || remaining <= 0 + ? unavailableArtifacts( + requested, + status === "cancelled" + ? "scenario request cancelled" + : "scenario duration limit reached", + ) + : await session.capture(requested, budget, remaining, signal); + return createStep({ + stepIndex, + stepId: action.step_id, + action: action.action, + status, + elapsedMs: Date.now() - actionStartedAt, + beforeUrl, + afterUrl: session.currentUrl(), + error, + eventStart, + eventEnd: session.lastEventSequence(), + artifacts, + }); +}; + +const globalCompleteness = ( + scenario: BrowserScenario, + session: BrowserScenarioSessionPort, + steps: readonly BrowserScenarioStep[], +): BrowserScenarioCompleteness => { + const missing = steps.flatMap( + ({ completeness }) => completeness.missing_sections, + ); + const truncated = steps.flatMap( + ({ completeness }) => completeness.truncated_sections, + ); + truncated.push(...session.eventTruncationSections()); + if (session.mode === "connect") missing.push("events"); + const status = + truncated.length > 0 + ? "truncated" + : missing.length > 0 + ? "incomplete" + : "complete"; + return browserScenarioCompletenessSchema.parse({ + status, + equality_eligible: status === "complete", + missing_sections: [...new Set(missing)].sort(), + truncated_sections: [...new Set(truncated)].sort(), + }); +}; + +const runScenario = async ( + factory: BrowserScenarioSessionFactory, + scenario: BrowserScenario, + options: ExecutionOptions, +): Promise => { + if (options.signal?.aborted === true) + throw new BrowserObservationError(OPERATION, "cancelled"); + const startedAt = Date.now(); + const budget = new BrowserScenarioCaptureBudget( + scenario.limits.max_screenshots, + scenario.limits.max_total_metadata_bytes, + ); + const session = await factory.open(scenario, { + ...(options.signal === undefined ? {} : { signal: options.signal }), + budget, + deadlineAt: startedAt + scenario.limits.max_duration_ms, + }); + const steps: BrowserScenarioStep[] = []; + let cleanup: "terminated-owned-process" | "disconnected-external" | undefined; + try { + steps.push( + await initialStep({ + session, + scenario, + budget, + elapsedMs: Date.now() - startedAt, + maximumTimeoutMs: + scenario.limits.max_duration_ms - (Date.now() - startedAt), + signal: options.signal, + }), + ); + let failed = false; + for (const [offset, action] of scenario.actions.entries()) { + const step = await executeStep({ + session, + scenario, + budget, + action, + stepIndex: offset + 1, + startedAt, + priorFailure: failed, + signal: options.signal, + }); + steps.push(step); + failed ||= step.status !== "completed"; + } + } finally { + cleanup = await session.close(); + } + const events = session.events(); + const completeness = globalCompleteness(scenario, session, steps); + return browserScenarioCaptureSchema.parse({ + schema_version: 1, + browser: { + mode: session.mode, + process_ownership: session.processOwnership, + cleanup, + product: session.product, + version: session.version, + }, + scenario: { + start_origin: new URL(scenario.start_url.url).origin, + allowed_origins: scenario.allowed_origins, + action_count: scenario.actions.length, + secret_references: scenario.secrets.map(({ secret_id: id }) => id).sort(), + }, + duration_ms: Date.now() - startedAt, + steps, + events, + completeness, + limitations: [ + "Event sequence records provider receipt order; simultaneous browser causality is not inferred.", + "Response bodies are not retained.", + "Storage values are hashed only after declared-secret redaction.", + ...(session.mode === "connect" + ? [ + "CDP attachment cannot recover pre-attach events or guarantee launch-time context options.", + ] + : []), + ], + }); +}; + +/** Controlled Playwright/CDP scenario driver with exact process ownership. */ +export class PlaywrightBrowserScenarioProvider + implements BrowserScenarioCapturePort +{ + constructor( + private readonly factory: BrowserScenarioSessionFactory = new PlaywrightScenarioSessionFactory(), + ) {} + + identity(): ProviderIdentity { + return PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY; + } + + async captureScenario( + scenario: BrowserScenario, + options: ExecutionOptions = {}, + ): Promise> { + try { + return ok(await runScenario(this.factory, scenario, options)); + } catch (cause) { + if (cause instanceof AnalysisError) return err(cause); + return err( + new ProviderAdapterError( + PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY.id, + OPERATION, + { cause }, + ), + ); + } + } +} diff --git a/src/browser/PlaywrightExecutionBoundary.ts b/src/browser/PlaywrightExecutionBoundary.ts new file mode 100644 index 000000000..19ce82dfb --- /dev/null +++ b/src/browser/PlaywrightExecutionBoundary.ts @@ -0,0 +1,54 @@ +import { + AnalysisCancelledError, + AnalysisTimeoutError, +} from "../domain/errors.js"; + +const OPERATION = "capture_browser_scenario" as const; + +export const withPlaywrightExecutionBoundary = ( + work: () => Promise, + maximumTimeoutMs: number, + signal?: AbortSignal, +): Promise => { + if (maximumTimeoutMs <= 0) + return Promise.reject( + new AnalysisTimeoutError(OPERATION, maximumTimeoutMs), + ); + if (signal?.aborted === true) + return Promise.reject(new AnalysisCancelledError(OPERATION)); + + return new Promise((resolve, reject) => { + let settled = false; + let timer: ReturnType | undefined; + const cleanup = (): void => { + if (timer !== undefined) clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + }; + const succeed = (value: Value): void => { + if (settled) return; + settled = true; + cleanup(); + resolve(value); + }; + const fail = (error: unknown): void => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }; + const onAbort = (): void => { + fail(new AnalysisCancelledError(OPERATION)); + }; + timer = setTimeout( + () => fail(new AnalysisTimeoutError(OPERATION, maximumTimeoutMs)), + maximumTimeoutMs, + ); + timer.unref(); + signal?.addEventListener("abort", onAbort, { once: true }); + if (signal?.aborted === true) { + onAbort(); + return; + } + void work().then(succeed, fail); + }); +}; diff --git a/src/browser/PlaywrightScenarioActions.ts b/src/browser/PlaywrightScenarioActions.ts new file mode 100644 index 000000000..19ae92331 --- /dev/null +++ b/src/browser/PlaywrightScenarioActions.ts @@ -0,0 +1,96 @@ +import type { Locator, Page } from "playwright-core"; + +import type { + BrowserScenarioAction, + BrowserScenarioValue, +} from "../domain/browserScenario.js"; +import type { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js"; + +type ScenarioLocator = Extract< + BrowserScenarioAction, + { readonly locator: unknown } +>["locator"]; + +const resolveLocator = (page: Page, locator: ScenarioLocator): Locator => { + switch (locator.kind) { + case "test_id": + return page.getByTestId(locator.value); + case "role": + return page.getByRole(locator.role, { + name: locator.name, + exact: locator.exact, + }); + case "css": + return page.locator(locator.selector); + } +}; + +const resolveValue = ( + value: BrowserScenarioValue, + secrets: BrowserScenarioSecrets, +): string => secrets.value(value); + +interface PerformActionInput { + readonly page: Page; + readonly action: BrowserScenarioAction; + readonly secrets: BrowserScenarioSecrets; + readonly defaultTimeoutMs: number; + readonly maximumTimeoutMs: number; +} + +/** Execute one admitted declarative action with no caller-supplied code. */ +export const performPlaywrightScenarioAction = async ( + input: PerformActionInput, +): Promise => { + const { page, action, secrets, defaultTimeoutMs, maximumTimeoutMs } = input; + const timeout = "timeout_ms" in action ? action.timeout_ms : undefined; + const options = { + timeout: Math.min(timeout ?? defaultTimeoutMs, maximumTimeoutMs), + }; + switch (action.action) { + case "goto": + await page.goto(secrets.url(action.destination), { + timeout: options.timeout, + waitUntil: action.wait_until, + }); + return; + case "click": + await resolveLocator(page, action.locator).click({ + ...options, + button: action.button, + clickCount: action.click_count, + }); + return; + case "fill": + await resolveLocator(page, action.locator).fill( + resolveValue(action.value, secrets), + options, + ); + return; + case "press": + await resolveLocator(page, action.locator).press(action.key, options); + return; + case "select_option": + await resolveLocator(page, action.locator).selectOption( + resolveValue(action.value, secrets), + options, + ); + return; + case "check": + await resolveLocator(page, action.locator).check(options); + return; + case "uncheck": + await resolveLocator(page, action.locator).uncheck(options); + return; + case "wait_for": + await resolveLocator(page, action.locator).waitFor({ + ...options, + state: action.state, + }); + return; + case "wait_for_timeout": + if (action.duration_ms > maximumTimeoutMs) + throw new Error("Scenario duration limit reached"); + await page.waitForTimeout(action.duration_ms); + } +}; diff --git a/src/browser/PlaywrightScenarioArtifacts.ts b/src/browser/PlaywrightScenarioArtifacts.ts new file mode 100644 index 000000000..17bdd0647 --- /dev/null +++ b/src/browser/PlaywrightScenarioArtifacts.ts @@ -0,0 +1,343 @@ +import { createHash } from "node:crypto"; + +import { z } from "zod"; +import type { BrowserContext, Page } from "playwright-core"; + +import { sanitizeBrowserUrl } from "../domain/browserObservation.js"; +import type { BrowserScenario } from "../domain/browserScenario.js"; +import { + browserStepArtifactsSchema, + type BrowserStepArtifacts, +} from "../domain/browserScenarioCapture.js"; +import type { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js"; + +const TEXT_ARTIFACT_MAX_BYTES = 1_048_576; + +const historyValueSchema = z.strictObject({ + length: z.number().int().min(0), + navigation_entries: z + .array( + z.strictObject({ + type: z.string(), + name: z.string(), + }), + ) + .max(10_000), +}); + +const storageValueSchema = z.strictObject({ + local_storage: z.array(z.tuple([z.string(), z.string()])).max(10_000), + session_storage: z.array(z.tuple([z.string(), z.string()])).max(10_000), +}); + +type SnapshotKind = BrowserScenario["capture"]["after_each_step"][number]; + +/** Shared retained-count budget for one scenario's screenshot artifacts. */ +export class BrowserScenarioCaptureBudget { + private screenshotCount = 0; + private metadataBytes = 0; + + constructor( + private readonly maximumScreenshots: number, + private readonly maximumMetadataBytes: number, + ) {} + + claimScreenshot(): boolean { + if (this.screenshotCount >= this.maximumScreenshots) return false; + this.screenshotCount += 1; + return true; + } + + claimMetadata(bytes: number): boolean { + if (this.metadataBytes + bytes > this.maximumMetadataBytes) return false; + this.metadataBytes += bytes; + return true; + } +} + +const notRequested = () => ({ state: "not_requested" as const }); +const missing = (reason: string) => ({ state: "missing" as const, reason }); +const truncated = (observed: number, retained: number, reason: string) => ({ + state: "truncated" as const, + observed, + retained, + reason, +}); + +const textArtifact = ( + text: string, + maximumBytes: number, + budget: BrowserScenarioCaptureBudget, +) => { + const bytes = Buffer.from(text); + if (bytes.byteLength > maximumBytes) + return truncated( + bytes.byteLength, + 0, + `artifact exceeds ${maximumBytes} bytes`, + ); + if (!budget.claimMetadata(bytes.byteLength)) + return truncated( + bytes.byteLength, + 0, + "scenario metadata byte limit reached", + ); + return { + state: "captured" as const, + value: { + sha256: createHash("sha256").update(bytes).digest("hex"), + bytes: bytes.byteLength, + text, + }, + }; +}; + +const metadataArtifact = ( + value: Value, + budget: BrowserScenarioCaptureBudget, +) => { + const bytes = Buffer.byteLength(JSON.stringify(value)); + return budget.claimMetadata(bytes) + ? { state: "captured" as const, value } + : truncated(bytes, 0, "scenario metadata byte limit reached"); +}; + +const captureScreenshot = async ( + page: Page, + scenario: BrowserScenario, + budget: BrowserScenarioCaptureBudget, +) => { + if (!budget.claimScreenshot()) + return truncated(1, 0, "scenario screenshot count limit reached"); + try { + const bytes = await page.screenshot({ + type: "png", + fullPage: false, + animations: "disabled", + caret: "hide", + }); + if (bytes.byteLength > scenario.limits.max_screenshot_bytes) + return truncated(bytes.byteLength, 0, "screenshot byte limit reached"); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + return { + state: "captured" as const, + value: { + uri: `rea://web-screenshot/sha256/${sha256}`, + sha256, + bytes: bytes.byteLength, + media_type: "image/png" as const, + data_base64: bytes.toString("base64"), + }, + }; + } catch { + return missing("screenshot capture failed"); + } +}; + +const captureDom = async (input: { + readonly page: Page; + readonly secrets: BrowserScenarioSecrets; + readonly maximumBytes: number; + readonly maximumNodes: number; + readonly budget: BrowserScenarioCaptureBudget; +}) => { + const { page, secrets, maximumBytes, maximumNodes, budget } = input; + try { + const nodes = await page.locator("*").count(); + if (nodes > maximumNodes) + return truncated(nodes, 0, "DOM node limit reached"); + return textArtifact( + secrets.redact(await page.content()), + Math.min(maximumBytes, TEXT_ARTIFACT_MAX_BYTES), + budget, + ); + } catch { + return missing("DOM capture failed"); + } +}; + +const captureAccessibility = async (input: { + readonly page: Page; + readonly secrets: BrowserScenarioSecrets; + readonly maximumBytes: number; + readonly maximumNodes: number; + readonly budget: BrowserScenarioCaptureBudget; +}) => { + const { page, secrets, maximumBytes, maximumNodes, budget } = input; + try { + const text = await page.locator("html").ariaSnapshot(); + const nodes = text.split("\n").filter((line) => line.trim() !== "").length; + if (nodes > maximumNodes) + return truncated(nodes, 0, "accessibility node limit reached"); + return textArtifact( + secrets.redact(text), + Math.min(maximumBytes, TEXT_ARTIFACT_MAX_BYTES), + budget, + ); + } catch { + return missing("accessibility capture failed"); + } +}; + +const navigationType = ( + value: string, +): "navigate" | "reload" | "back_forward" | "prerender" | "unknown" => { + switch (value) { + case "navigate": + case "reload": + case "back_forward": + case "prerender": + return value; + default: + return "unknown"; + } +}; + +const captureHistory = async ( + page: Page, + budget: BrowserScenarioCaptureBudget, +) => { + try { + const raw = historyValueSchema.parse( + await page.evaluate(`(() => ({ + length: window.history.length, + navigation_entries: performance.getEntriesByType("navigation").map( + entry => ({ + type: typeof entry.type === "string" ? entry.type : "unknown", + name: entry.name + }) + ) + }))()`), + ); + if (raw.navigation_entries.length > 256) + return truncated( + raw.navigation_entries.length, + 0, + "navigation history entry limit reached", + ); + const value = { + length: raw.length, + current_url: sanitizeBrowserUrl(page.url()), + navigation_entries: raw.navigation_entries.map(({ type, name }) => ({ + type: navigationType(type), + name: sanitizeBrowserUrl(name), + })), + }; + return metadataArtifact(value, budget); + } catch { + return missing("history capture failed"); + } +}; + +const captureStorage = async (input: { + readonly context: BrowserContext; + readonly page: Page; + readonly scenario: BrowserScenario; + readonly secrets: BrowserScenarioSecrets; + readonly budget: BrowserScenarioCaptureBudget; +}) => { + const { context, page, scenario, secrets, budget } = input; + try { + const cookies = await context.cookies(scenario.allowed_origins); + const pageStorage = storageValueSchema.parse( + await page.evaluate(`(() => ({ + local_storage: Object.entries(window.localStorage), + session_storage: Object.entries(window.sessionStorage) + }))()`), + ); + const observed = + cookies.length + + pageStorage.local_storage.length + + pageStorage.session_storage.length; + if (observed > scenario.limits.max_storage_entries) + return truncated(observed, 0, "storage entry limit reached"); + const value = { + cookies: cookies.map((cookie) => ({ + name: cookie.name, + domain: cookie.domain, + path: cookie.path, + secure: cookie.secure, + http_only: cookie.httpOnly, + same_site: cookie.sameSite, + ...secrets.fingerprint(cookie.value), + })), + local_storage: pageStorage.local_storage.map(([name, value]) => ({ + name, + ...secrets.fingerprint(value), + })), + session_storage: pageStorage.session_storage.map(([name, value]) => ({ + name, + ...secrets.fingerprint(value), + })), + }; + return metadataArtifact(value, budget); + } catch { + return missing("storage capture failed"); + } +}; + +const isAllowedPage = ( + page: Page, + allowedOrigins: ReadonlySet, +): boolean => { + try { + return allowedOrigins.has(new URL(page.url()).origin); + } catch { + return false; + } +}; + +/** Capture selected step artifacts without retaining data after scope loss. */ +export const capturePlaywrightStepArtifacts = async (input: { + readonly context: BrowserContext; + readonly page: Page; + readonly scenario: BrowserScenario; + readonly secrets: BrowserScenarioSecrets; + readonly requested: ReadonlySet; + readonly budget: BrowserScenarioCaptureBudget; +}): Promise => { + const { context, page, scenario, secrets, requested, budget } = input; + const allowed = isAllowedPage(page, new Set(scenario.allowed_origins)); + const denied = () => missing("current page is outside approved origins"); + const state = ( + kind: SnapshotKind, + capture: () => Promise, + ): Promise< + Value | ReturnType | ReturnType + > => + !requested.has(kind) + ? Promise.resolve(notRequested()) + : allowed || kind === "url" + ? capture() + : Promise.resolve(denied()); + return browserStepArtifactsSchema.parse({ + screenshot: await state("screenshot", () => + captureScreenshot(page, scenario, budget), + ), + dom: await state("dom", () => + captureDom({ + page, + secrets, + maximumBytes: scenario.limits.max_total_metadata_bytes, + maximumNodes: scenario.limits.max_dom_nodes, + budget, + }), + ), + accessibility: await state("accessibility", () => + captureAccessibility({ + page, + secrets, + maximumBytes: scenario.limits.max_total_metadata_bytes, + maximumNodes: scenario.limits.max_accessibility_nodes, + budget, + }), + ), + url: await state("url", async () => + metadataArtifact(sanitizeBrowserUrl(page.url()), budget), + ), + history: await state("history", () => captureHistory(page, budget)), + storage: await state("storage", () => + captureStorage({ context, page, scenario, secrets, budget }), + ), + }); +}; diff --git a/src/browser/PlaywrightScenarioBrowser.ts b/src/browser/PlaywrightScenarioBrowser.ts new file mode 100644 index 000000000..f3e74b121 --- /dev/null +++ b/src/browser/PlaywrightScenarioBrowser.ts @@ -0,0 +1,195 @@ +import { chmod, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + chromium, + type Browser, + type BrowserContext, + type Page, +} from "playwright-core"; + +import type { BrowserScenario } from "../domain/browserScenario.js"; +import { BrowserObservationError } from "../domain/errors.js"; + +const OPERATION = "capture_browser_scenario" as const; + +export interface OpenedScenarioBrowser { + readonly context: BrowserContext; + readonly page: Page; + readonly browser: Browser; + readonly profilePath: string | undefined; +} + +const allowedBrowserEnvironment = ( + environment: Readonly>, +): Record => { + const allowed = [ + "DBUS_SESSION_BUS_ADDRESS", + "DISPLAY", + "HOME", + "LANG", + "LD_LIBRARY_PATH", + "PATH", + "SystemRoot", + "TEMP", + "TMP", + "TMPDIR", + "WAYLAND_DISPLAY", + "XAUTHORITY", + "XDG_RUNTIME_DIR", + ]; + return Object.fromEntries( + allowed + .map((name) => [name, environment[name]] as const) + .filter( + (entry): entry is readonly [string, string] => entry[1] !== undefined, + ), + ); +}; + +const originAllowed = ( + value: string, + allowedOrigins: ReadonlySet, +): boolean => { + try { + return allowedOrigins.has(new URL(value).origin); + } catch { + return false; + } +}; + +const findConnectedPage = async ( + browser: Browser, + targetId: string, + allowedOrigins: ReadonlySet, +): Promise<{ readonly context: BrowserContext; readonly page: Page }> => { + for (const context of browser.contexts()) + for (const page of context.pages()) { + const session = await context.newCDPSession(page); + try { + const { targetInfo } = await session.send("Target.getTargetInfo"); + if (targetInfo.targetId !== targetId) continue; + if ( + page.url() !== "about:blank" && + !originAllowed(page.url(), allowedOrigins) + ) + throw new BrowserObservationError(OPERATION, "target_not_allowed"); + return { context, page }; + } finally { + await session.detach(); + } + } + throw new BrowserObservationError(OPERATION, "target_not_found"); +}; + +const configureAttachedEnvironment = async ( + context: BrowserContext, + page: Page, + scenario: BrowserScenario, +): Promise => { + await page.setViewportSize({ + width: scenario.environment.viewport.width, + height: scenario.environment.viewport.height, + }); + await page.emulateMedia({ + colorScheme: scenario.environment.color_scheme, + reducedMotion: scenario.environment.reduced_motion, + }); + const session = await context.newCDPSession(page); + try { + await session.send("Emulation.setDeviceMetricsOverride", { + width: scenario.environment.viewport.width, + height: scenario.environment.viewport.height, + deviceScaleFactor: scenario.environment.viewport.device_scale_factor, + mobile: false, + }); + await session.send("Emulation.setLocaleOverride", { + locale: scenario.environment.locale, + }); + await session.send("Emulation.setTimezoneOverride", { + timezoneId: scenario.environment.timezone, + }); + } finally { + await session.detach(); + } +}; + +export const openPlaywrightScenarioBrowser = async ( + scenario: BrowserScenario, + environment: Readonly>, + maximumTimeoutMs: number, +): Promise => { + if (scenario.browser.mode === "connect") { + const browser = await chromium.connectOverCDP( + scenario.browser.cdp_endpoint, + { + timeout: Math.min( + scenario.limits.navigation_timeout_ms, + maximumTimeoutMs, + ), + }, + ); + try { + const target = await findConnectedPage( + browser, + scenario.browser.target_id, + new Set(scenario.allowed_origins), + ); + await configureAttachedEnvironment(target.context, target.page, scenario); + return { ...target, browser, profilePath: undefined }; + } catch (error) { + await browser.close(); + throw error; + } + } + + const profilePath = await mkdtemp(join(tmpdir(), "rea-browser-scenario-")); + await chmod(profilePath, 0o700); + try { + const context = await chromium.launchPersistentContext(profilePath, { + executablePath: scenario.browser.executable_path, + headless: true, + acceptDownloads: false, + viewport: { + width: scenario.environment.viewport.width, + height: scenario.environment.viewport.height, + }, + deviceScaleFactor: scenario.environment.viewport.device_scale_factor, + locale: scenario.environment.locale, + timezoneId: scenario.environment.timezone, + colorScheme: scenario.environment.color_scheme, + reducedMotion: scenario.environment.reduced_motion, + serviceWorkers: "block", + env: allowedBrowserEnvironment(environment), + handleSIGHUP: false, + handleSIGINT: false, + handleSIGTERM: false, + timeout: maximumTimeoutMs, + }); + const browser = context.browser(); + if (browser === null) { + await context.close(); + throw new BrowserObservationError(OPERATION, "protocol_error"); + } + const page = context.pages()[0] ?? (await context.newPage()); + return { context, page, browser, profilePath }; + } catch (error) { + await rm(profilePath, { recursive: true, force: true, maxRetries: 3 }); + throw error; + } +}; + +export const closePlaywrightScenarioBrowser = async ({ + context, + browser, + profilePath, +}: OpenedScenarioBrowser): Promise => { + try { + if (profilePath === undefined) await browser.close(); + else await context.close(); + } finally { + if (profilePath !== undefined) + await rm(profilePath, { recursive: true, force: true, maxRetries: 3 }); + } +}; diff --git a/src/browser/PlaywrightScenarioEvents.ts b/src/browser/PlaywrightScenarioEvents.ts new file mode 100644 index 000000000..9b1e1cea4 --- /dev/null +++ b/src/browser/PlaywrightScenarioEvents.ts @@ -0,0 +1,375 @@ +import type { + ConsoleMessage, + Download, + Frame, + Page, + Request, + Response, + WebSocket, + Worker, +} from "playwright-core"; + +import { sanitizeBrowserUrl } from "../domain/browserObservation.js"; +import { + browserScenarioEventSchema, + type BrowserScenarioEvent, +} from "../domain/browserScenarioCapture.js"; +import type { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js"; +import type { BrowserScenarioCaptureBudget } from "./PlaywrightScenarioArtifacts.js"; +import type { BrowserScenario } from "../domain/browserScenario.js"; + +type EventName = + | "console" + | "page-errors" + | "network" + | "websockets" + | "frames" + | "workers" + | "popups" + | "downloads"; + +type UnindexedEvent = BrowserScenarioEvent extends infer Event + ? Event extends BrowserScenarioEvent + ? Omit + : never + : never; + +type LimitedSection = "frames" | "workers" | "popups" | "websockets"; +type TruncatedSection = "events" | LimitedSection; + +interface EventCaptureOptions { + readonly page: Page; + readonly enabled: ReadonlySet; + readonly limits: Pick< + BrowserScenario["limits"], + | "max_events" + | "max_frames" + | "max_workers" + | "max_popups" + | "max_websockets" + >; + readonly secrets: BrowserScenarioSecrets; + readonly budget: BrowserScenarioCaptureBudget; + readonly allowedOrigins: readonly string[]; +} + +/** Bounded, arrival-ordered Playwright event capture with current-step attribution. */ +export class PlaywrightScenarioEvents { + private readonly items: BrowserScenarioEvent[] = []; + private readonly truncated = new Set(); + private readonly admitted = { + frames: new WeakSet(), + workers: new WeakSet(), + popups: new WeakSet(), + websockets: new WeakSet(), + }; + private readonly rejected = { + frames: new WeakSet(), + workers: new WeakSet(), + popups: new WeakSet(), + websockets: new WeakSet(), + }; + private readonly counts: Record = { + frames: 0, + workers: 0, + popups: 0, + websockets: 0, + }; + private dropped = 0; + private stepIndex = 0; + private sequence = 0; + private readonly enabled: ReadonlySet; + private readonly limits: EventCaptureOptions["limits"]; + private readonly secrets: BrowserScenarioSecrets; + private readonly budget: BrowserScenarioCaptureBudget; + private readonly allowedOrigins: ReadonlySet; + + constructor(options: EventCaptureOptions) { + this.enabled = options.enabled; + this.limits = options.limits; + this.secrets = options.secrets; + this.budget = options.budget; + this.allowedOrigins = new Set(options.allowedOrigins); + this.observePage(options.page); + } + + setStep(index: number): void { + this.stepIndex = index; + } + + nextSequence(): number { + return this.sequence + 1; + } + + lastSequence(): number { + return this.sequence; + } + + result(): { + readonly retained: number; + readonly dropped: number; + readonly items: readonly BrowserScenarioEvent[]; + } { + return { + retained: this.items.length, + dropped: this.dropped, + items: this.items, + }; + } + + truncationSections(): readonly TruncatedSection[] { + return [...this.truncated].sort(); + } + + private push(event: UnindexedEvent): void { + this.sequence += 1; + if (this.items.length >= this.limits.max_events) { + this.dropped += 1; + this.truncated.add("events"); + return; + } + const parsed = browserScenarioEventSchema.parse({ + ...event, + sequence: this.sequence, + step_index: this.stepIndex, + }); + if (!this.budget.claimMetadata(Buffer.byteLength(JSON.stringify(parsed)))) { + this.dropped += 1; + this.truncated.add("events"); + return; + } + this.items.push(parsed); + } + + private admit( + section: LimitedSection, + value: object, + maximum: number, + ): boolean { + if (this.admitted[section].has(value)) return true; + if (this.rejected[section].has(value)) return false; + if (this.counts[section] >= maximum) { + this.rejected[section].add(value); + this.sequence += 1; + this.dropped += 1; + this.truncated.add("events"); + this.truncated.add(section); + return false; + } + this.admitted[section].add(value); + this.counts[section] += 1; + return true; + } + + private observePage(page: Page): void { + if (this.enabled.has("console")) + page.on("console", (message) => { + if (this.pageInScope(page)) this.console(message); + }); + if (this.enabled.has("page-errors")) + page.on("pageerror", (error) => { + if (this.pageInScope(page)) + this.push({ + kind: "page-error", + message: this.secrets.redact(error.message).slice(0, 65_536), + stack: + error.stack === undefined + ? null + : this.secrets.redact(error.stack).slice(0, 262_144), + }); + }); + if (this.enabled.has("network")) { + page.on("request", (request) => { + if (this.pageInScope(page)) this.request("request", request); + }); + page.on("response", (response) => { + if (this.pageInScope(page)) this.response(response); + }); + page.on("requestfailed", (request) => { + if (this.pageInScope(page)) this.request("request-failed", request); + }); + } + if (this.enabled.has("websockets")) + page.on("websocket", (socket) => { + if (this.pageInScope(page)) this.webSocket(page, socket); + }); + if (this.enabled.has("frames")) { + page.on("frameattached", (frame) => { + if (this.pageInScope(page)) this.frame("frame-attached", frame); + }); + page.on("framedetached", (frame) => { + if (this.pageInScope(page)) this.frame("frame-detached", frame); + }); + page.on("framenavigated", (frame) => { + if (this.pageInScope(page)) this.frame("frame-navigated", frame); + }); + } + if (this.enabled.has("workers")) + page.on("worker", (worker) => { + if (this.pageInScope(page)) this.worker(page, worker); + }); + if (this.enabled.has("popups")) + page.on("popup", (popup) => { + if (this.pageInScope(page)) this.popup(popup); + }); + if (this.enabled.has("downloads")) + page.on("download", (download) => { + if (this.pageInScope(page)) this.download(download); + }); + } + + private pageInScope(page: Page): boolean { + try { + return this.allowedOrigins.has(new URL(page.url()).origin); + } catch { + return false; + } + } + + private safeUrl(value: string) { + return sanitizeBrowserUrl(this.secrets.redact(value)); + } + + private console(message: ConsoleMessage): void { + const location = message.location(); + this.push({ + kind: "console", + level: this.secrets.redact(message.type()).slice(0, 64), + text: this.secrets.redact(message.text()).slice(0, 65_536), + url: location.url === "" ? null : this.safeUrl(location.url), + }); + } + + private request(kind: "request" | "request-failed", request: Request): void { + this.push({ + kind, + method: this.secrets.redact(request.method()).slice(0, 32), + url: this.safeUrl(request.url()), + resource_type: this.secrets.redact(request.resourceType()).slice(0, 64), + status: null, + header_names: Object.keys(request.headers()) + .map((name) => this.secrets.redact(name).slice(0, 256)) + .sort() + .slice(0, 256), + failure: + kind === "request-failed" + ? this.secrets + .redact(request.failure()?.errorText ?? "unknown") + .slice(0, 1_024) + : null, + }); + } + + private response(response: Response): void { + const request = response.request(); + this.push({ + kind: "response", + method: this.secrets.redact(request.method()).slice(0, 32), + url: this.safeUrl(response.url()), + resource_type: this.secrets.redact(request.resourceType()).slice(0, 64), + status: response.status(), + header_names: Object.keys(response.headers()) + .map((name) => this.secrets.redact(name).slice(0, 256)) + .sort() + .slice(0, 256), + failure: null, + }); + } + + private webSocket(page: Page, socket: WebSocket): void { + if (!this.admit("websockets", socket, this.limits.max_websockets)) return; + const url = this.safeUrl(socket.url()); + this.push({ kind: "websocket-opened", url }); + socket.on("framesent", ({ payload }) => { + if (this.pageInScope(page)) + this.webSocketFrame("websocket-frame-sent", url, payload); + }); + socket.on("framereceived", ({ payload }) => { + if (this.pageInScope(page)) + this.webSocketFrame("websocket-frame-received", url, payload); + }); + socket.on("close", () => { + if (this.pageInScope(page)) this.push({ kind: "websocket-closed", url }); + }); + } + + private webSocketFrame( + kind: "websocket-frame-sent" | "websocket-frame-received", + url: ReturnType, + payload: string | Buffer, + ): void { + const bytes = Buffer.byteLength(payload); + const isText = typeof payload === "string"; + const text = isText ? this.secrets.redact(payload).slice(0, 65_536) : null; + this.push({ + kind, + url, + payload_type: isText ? "text" : "binary", + payload_bytes: bytes, + payload_text: text, + truncated: isText && text !== null && text.length < payload.length, + }); + } + + private frame( + kind: "frame-attached" | "frame-detached" | "frame-navigated", + frame: Frame, + ): void { + if (!this.admit("frames", frame, this.limits.max_frames)) return; + this.push({ + kind, + url: frame.url() === "" ? null : this.safeUrl(frame.url()), + name: + frame.name() === "" + ? null + : this.secrets.redact(frame.name()).slice(0, 1_024), + }); + } + + private worker(page: Page, worker: Worker): void { + if (!this.admit("workers", worker, this.limits.max_workers)) return; + const details = { + url: this.safeUrl(worker.url()), + name: null, + }; + this.push({ kind: "worker-created", ...details }); + worker.on("close", () => { + if (this.pageInScope(page)) + this.push({ kind: "worker-closed", ...details }); + }); + } + + private popup(page: Page): void { + if (!this.pageInScope(page)) { + void page.close().catch(() => undefined); + return; + } + if (!this.admit("popups", page, this.limits.max_popups)) return; + const opened = { + url: page.url() === "" ? null : this.safeUrl(page.url()), + name: null, + }; + this.push({ kind: "popup-opened", ...opened }); + page.on("close", () => + this.pageInScope(page) + ? this.push({ + kind: "popup-closed", + url: page.url() === "" ? null : this.safeUrl(page.url()), + name: null, + }) + : undefined, + ); + this.observePage(page); + } + + private download(download: Download): void { + this.push({ + kind: "download-cancelled", + suggested_filename: this.secrets + .redact(download.suggestedFilename()) + .slice(0, 1_024), + url: this.safeUrl(download.url()), + }); + void download.cancel().catch(() => undefined); + } +} diff --git a/src/browser/PlaywrightScenarioSession.ts b/src/browser/PlaywrightScenarioSession.ts new file mode 100644 index 000000000..a967f9619 --- /dev/null +++ b/src/browser/PlaywrightScenarioSession.ts @@ -0,0 +1,460 @@ +import { + type BrowserContext, + type Page, + type Route, + type WebSocketRoute, +} from "playwright-core"; + +import type { + BrowserScenario, + BrowserScenarioAction, +} from "../domain/browserScenario.js"; +import { BrowserObservationError } from "../domain/errors.js"; +import type { + BrowserScenarioSessionFactory, + BrowserScenarioSessionPort, +} from "./BrowserScenarioSessionPort.js"; +import { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js"; +import { + closePlaywrightScenarioBrowser, + openPlaywrightScenarioBrowser, + type OpenedScenarioBrowser, +} from "./PlaywrightScenarioBrowser.js"; +import { performPlaywrightScenarioAction } from "./PlaywrightScenarioActions.js"; +import { + BrowserScenarioCaptureBudget, + capturePlaywrightStepArtifacts, +} from "./PlaywrightScenarioArtifacts.js"; +import { PlaywrightScenarioEvents } from "./PlaywrightScenarioEvents.js"; +import { withPlaywrightExecutionBoundary } from "./PlaywrightExecutionBoundary.js"; + +const OPERATION = "capture_browser_scenario" as const; + +const originAllowed = ( + value: string, + allowedOrigins: ReadonlySet, +): boolean => { + try { + return allowedOrigins.has(new URL(value).origin); + } catch { + return false; + } +}; + +const storageSeeds = ( + blocks: BrowserScenario["storage"]["local_storage"], + secrets: BrowserScenarioSecrets, +): Record => { + const seeds: Record = {}; + for (const { origin, entries } of blocks) + (seeds[origin] ??= []).push( + ...entries.map(({ name, value }) => [name, secrets.value(value)]), + ); + return seeds; +}; + +const installStorageSeeds = async ( + context: BrowserContext, + page: Page, + scenario: BrowserScenario, + secrets: BrowserScenarioSecrets, +): Promise => { + await context.addCookies( + scenario.storage.cookies.map((cookie) => ({ + name: cookie.name, + value: secrets.value(cookie.value), + url: secrets.url(cookie.destination), + httpOnly: cookie.http_only, + secure: cookie.secure, + sameSite: cookie.same_site, + })), + ); + const storage = { + local: storageSeeds(scenario.storage.local_storage, secrets), + session: storageSeeds(scenario.storage.session_storage, secrets), + }; + const payload = JSON.stringify(storage).replaceAll("<", "\\u003c"); + await page.addInitScript(`(() => { + const seeds = ${payload}; + const local = seeds.local[window.location.origin] ?? []; + const session = seeds.session[window.location.origin] ?? []; + for (const [name, value] of local) window.localStorage.setItem(name, value); + for (const [name, value] of session) + window.sessionStorage.setItem(name, value); + })()`); +}; + +const installHttpPolicy = async (input: { + readonly context: BrowserContext; + readonly scenario: BrowserScenario; + readonly secrets: BrowserScenarioSecrets; + readonly ownedPages: Set; + readonly claimOwnedPage: (page: Page) => Promise; +}): Promise => { + const { context, scenario, secrets, ownedPages, claimOwnedPage } = input; + const routes = + scenario.request_replay.mode === "exact" + ? new Map( + scenario.request_replay.routes.map((route) => [ + `${route.method} ${secrets.url(route.request)}`, + route, + ]), + ) + : new Map(); + await context.route("**/*", async (route, request) => { + let requestPage: Page | undefined; + try { + requestPage = request.frame().page(); + } catch { + requestPage = undefined; + } + if ( + scenario.browser.mode === "connect" && + requestPage !== undefined && + !ownedPages.has(requestPage) + ) + await claimOwnedPage(requestPage); + if ( + scenario.browser.mode === "connect" && + (requestPage === undefined || !ownedPages.has(requestPage)) + ) { + await route.continue(); + return; + } + if (!originAllowed(request.url(), new Set(scenario.allowed_origins))) { + await route.abort("blockedbyclient"); + return; + } + const replay = routes.get(`${request.method()} ${request.url()}`); + if (replay !== undefined) { + await fulfillReplayRoute(route, replay.response, secrets); + return; + } + if ( + scenario.request_replay.mode === "exact" && + scenario.request_replay.unmatched === "abort" + ) { + await route.abort("blockedbyclient"); + return; + } + await route.continue(); + }); +}; + +type ReplayResponse = Extract< + BrowserScenario["request_replay"], + { readonly mode: "exact" } +>["routes"][number]["response"]; + +const fulfillReplayRoute = async ( + route: Route, + response: ReplayResponse, + secrets: BrowserScenarioSecrets, +): Promise => { + if (response.kind === "redirect") { + await route.fulfill({ + status: response.status, + headers: { location: secrets.url(response.destination) }, + }); + return; + } + await route.fulfill({ + status: response.status, + headers: Object.fromEntries( + response.headers.map(({ name, value }) => [name, secrets.value(value)]), + ), + ...(response.body === undefined + ? {} + : { body: secrets.value(response.body) }), + }); +}; + +const installWebSocketPolicy = async ( + owner: BrowserContext | Page, + scenario: BrowserScenario, +): Promise => { + const allowed = new Set(scenario.allowed_origins); + await owner.routeWebSocket("**/*", async (route: WebSocketRoute) => { + const url = new URL(route.url()); + const httpOrigin = `${url.protocol === "wss:" ? "https:" : "http:"}//${url.host}`; + if ( + !allowed.has(httpOrigin) || + (scenario.request_replay.mode === "exact" && + scenario.request_replay.unmatched === "abort") + ) { + await route.close({ code: 1008, reason: "outside scenario policy" }); + return; + } + route.connectToServer(); + }); +}; + +const blockAttachedServiceWorkers = async (page: Page): Promise => { + const controller = await page.evaluate( + "Boolean(navigator.serviceWorker?.controller)", + ); + if (controller) + throw new BrowserObservationError(OPERATION, "target_not_allowed"); + const blockRegistration = `(() => { + if (!("serviceWorker" in navigator)) return; + void navigator.serviceWorker.getRegistrations() + .then((registrations) => Promise.all(registrations.map((item) => item.unregister()))); + Object.defineProperty(ServiceWorkerContainer.prototype, "register", { + configurable: false, + value: () => Promise.reject(new Error("service workers are blocked by scenario policy")) + }); + })()`; + await page.addInitScript(blockRegistration); + await page.evaluate(blockRegistration); +}; + +const initializePage = async ( + context: BrowserContext, + page: Page, + scenario: BrowserScenario, + secrets: BrowserScenarioSecrets, +): Promise> => { + context.setDefaultTimeout(scenario.limits.action_timeout_ms); + context.setDefaultNavigationTimeout(scenario.limits.navigation_timeout_ms); + const ownedPages = new Set([page]); + if (scenario.browser.mode === "connect") + await blockAttachedServiceWorkers(page); + await installWebSocketPolicy( + scenario.browser.mode === "launch" ? context : page, + scenario, + ); + const claimOwnedPage = async (candidate: Page): Promise => { + if (ownedPages.has(candidate)) return true; + const opener = await candidate.opener(); + if (opener === null || !ownedPages.has(opener)) return false; + await installWebSocketPolicy(candidate, scenario); + ownedPages.add(candidate); + return true; + }; + await installHttpPolicy({ + context, + scenario, + secrets, + ownedPages, + claimOwnedPage, + }); + page.on("popup", (popup) => { + if (scenario.browser.mode === "connect") + void claimOwnedPage(popup).catch(async () => { + await popup.close().catch(() => undefined); + }); + else ownedPages.add(popup); + }); + await installStorageSeeds(context, page, scenario, secrets); + return ownedPages; +}; + +export class PlaywrightScenarioSession implements BrowserScenarioSessionPort { + readonly mode: "launch" | "connect"; + readonly processOwnership: "provider-owned" | "external"; + readonly product = "Chromium"; + readonly version: string; + readonly initialUrl: string; + private closed = false; + + private constructor( + private readonly opened: OpenedScenarioBrowser, + private readonly scenario: BrowserScenario, + private readonly secrets: BrowserScenarioSecrets, + private readonly eventCapture: PlaywrightScenarioEvents, + ) { + this.mode = scenario.browser.mode; + this.processOwnership = + scenario.browser.mode === "launch" ? "provider-owned" : "external"; + this.version = opened.browser.version(); + this.initialUrl = opened.page.url(); + } + + static async open( + scenario: BrowserScenario, + environment: Readonly>, + options: { + readonly signal?: AbortSignal; + readonly budget: BrowserScenarioCaptureBudget; + readonly deadlineAt: number; + }, + ): Promise { + const remaining = (): number => options.deadlineAt - Date.now(); + if (options.signal?.aborted === true) + throw new BrowserObservationError(OPERATION, "cancelled"); + const secrets = BrowserScenarioSecrets.resolve(scenario, environment); + if (secrets === undefined) + throw new BrowserObservationError(OPERATION, "secret_unavailable"); + const opening = openPlaywrightScenarioBrowser( + scenario, + environment, + remaining(), + ); + let opened: OpenedScenarioBrowser; + try { + opened = await withPlaywrightExecutionBoundary( + () => opening, + remaining(), + options.signal, + ); + } catch (error) { + void opening + .then((lateOpened) => closePlaywrightScenarioBrowser(lateOpened)) + .catch(() => undefined); + throw error; + } + try { + await withPlaywrightExecutionBoundary( + () => initializePage(opened.context, opened.page, scenario, secrets), + remaining(), + options.signal, + ); + const events = new PlaywrightScenarioEvents({ + page: opened.page, + enabled: new Set(scenario.capture.events), + limits: scenario.limits, + secrets, + budget: options.budget, + allowedOrigins: scenario.allowed_origins, + }); + const session = new PlaywrightScenarioSession( + opened, + scenario, + secrets, + events, + ); + await withPlaywrightExecutionBoundary( + () => + opened.page.goto(secrets.url(scenario.start_url), { + waitUntil: "load", + timeout: Math.min( + scenario.limits.navigation_timeout_ms, + remaining(), + ), + }), + remaining(), + options.signal, + ); + return session; + } catch (error) { + await closePlaywrightScenarioBrowser(opened); + throw error; + } + } + + currentUrl(): string { + return this.opened.page.url(); + } + + setStep(index: number): void { + this.eventCapture.setStep(index); + } + + nextEventSequence(): number { + return this.eventCapture.nextSequence(); + } + + lastEventSequence(): number { + return this.eventCapture.lastSequence(); + } + + events() { + return this.eventCapture.result(); + } + + eventTruncationSections() { + return this.eventCapture.truncationSections(); + } + + async perform( + action: BrowserScenarioAction, + maximumTimeoutMs: number, + signal?: AbortSignal, + ): Promise { + await withPlaywrightExecutionBoundary( + () => + performPlaywrightScenarioAction({ + page: this.opened.page, + action, + secrets: this.secrets, + defaultTimeoutMs: this.scenario.limits.action_timeout_ms, + maximumTimeoutMs, + }), + maximumTimeoutMs, + signal, + ); + } + + async capture( + requested: ReadonlySet< + BrowserScenario["capture"]["after_each_step"][number] + >, + budget: BrowserScenarioCaptureBudget, + maximumTimeoutMs: number, + signal?: AbortSignal, + ) { + return withPlaywrightExecutionBoundary( + () => + capturePlaywrightStepArtifacts({ + context: this.opened.context, + page: this.opened.page, + scenario: this.scenario, + secrets: this.secrets, + requested, + budget, + }), + maximumTimeoutMs, + signal, + ); + } + + async close() { + if (this.closed) + return this.mode === "launch" + ? ("terminated-owned-process" as const) + : ("disconnected-external" as const); + this.closed = true; + await closePlaywrightScenarioBrowser(this.opened); + return this.mode === "launch" + ? ("terminated-owned-process" as const) + : ("disconnected-external" as const); + } + + redactError(error: unknown): string { + return this.secrets + .redact(error instanceof Error ? error.message : "browser action failed") + .slice(0, 4_096); + } +} + +/** Production Playwright/CDP session factory. */ +export class PlaywrightScenarioSessionFactory + implements BrowserScenarioSessionFactory +{ + constructor( + private readonly environment: Readonly< + Record + > = process.env, + ) {} + + open( + scenario: BrowserScenario, + options: { + readonly signal?: AbortSignal; + readonly budget?: BrowserScenarioCaptureBudget; + readonly deadlineAt?: number; + } = {}, + ): Promise { + return PlaywrightScenarioSession.open(scenario, this.environment, { + ...(options.signal === undefined ? {} : { signal: options.signal }), + budget: + options.budget ?? + new BrowserScenarioCaptureBudget( + scenario.limits.max_screenshots, + scenario.limits.max_total_metadata_bytes, + ), + deadlineAt: + options.deadlineAt ?? Date.now() + scenario.limits.max_duration_ms, + }); + } +} diff --git a/src/browser/V8InspectorCaptureProjection.ts b/src/browser/V8InspectorCaptureProjection.ts new file mode 100644 index 000000000..1d0adf110 --- /dev/null +++ b/src/browser/V8InspectorCaptureProjection.ts @@ -0,0 +1,159 @@ +import { createHash } from "node:crypto"; + +import type { + JavaScriptRuntimeLocation, + JavaScriptRuntimeObservation, + JavaScriptRuntimeTargetList, + ObserveJavaScriptRuntimeInput, +} from "../domain/javascriptRuntimeObservation.js"; +import { authorizeRuntimeLocation } from "./JavaScriptRuntimeScope.js"; +import type { CaptureState, ScriptDraft } from "./V8InspectorProvider.js"; +import type { AuthorizedV8InspectorTarget } from "./V8InspectorEndpoint.js"; + +type ExclusionReason = + | "outside_file_roots" + | "outside_origins" + | "unsupported_location"; + +/** Empty durable-location exclusion counters. */ +export const createInspectorExclusionCounts = (): Record< + ExclusionReason, + number +> => ({ + outside_file_roots: 0, + outside_origins: 0, + unsupported_location: 0, +}); + +/** Stable safety and protocol limitations shared by target-list Evidence. */ +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.", + "No runtime graph depth is traversed because passive Inspector events do not establish require/import caller edges.", +]; + +interface FinalizeCaptureInput { + readonly input: ObserveJavaScriptRuntimeInput; + readonly runtime: JavaScriptRuntimeTargetList["runtime"]; + readonly target: AuthorizedV8InspectorTarget; + readonly roots: readonly string[]; + readonly state: CaptureState; +} + +/** Canonically authorize, deduplicate, and sort one bounded raw capture. */ +export const finalizeInspectorCapture = async ({ + input, + runtime, + target, + roots, + state, +}: FinalizeCaptureInput): Promise => { + const exclusions = createInspectorExclusionCounts(); + const scripts = new Map< + string, + JavaScriptRuntimeObservation["scripts"]["items"][number] + >(); + for (const draft of state.scripts) { + const decision = await authorizeRuntimeLocation( + draft.rawUrl, + roots, + input.allowed_origins, + ); + if (!decision.allowed) { + exclusions[decision.reason] += 1; + continue; + } + const script = scriptFromDraft(draft, decision.location); + scripts.set(script.script_key, script); + } + const items = [...scripts.values()].sort((left, right) => + left.script_key < right.script_key + ? -1 + : left.script_key > right.script_key + ? 1 + : 0, + ); + const contexts = [...state.contexts.values()] + .map((context) => ({ + context_key: context.contextKey, + state: context.state, + name: null, + origin: context.origin, + })) + .sort((left, right) => + left.context_key < right.context_key + ? -1 + : left.context_key > right.context_key + ? 1 + : 0, + ); + return { + schema_version: 1, + runtime, + target: { + target_id: target.id, + protocol_type: target.type, + attached: target.attached, + location: target.location, + runtime_kind: input.runtime_kind, + runtime_kind_authority: "caller-declared-unverified", + }, + capture: { + observation_ms: input.observation_ms, + events_observed: state.eventsObserved, + events_retained: state.eventsRetained, + events_dropped: state.eventsDropped, + metadata_bytes_retained: state.metadataBytes, + truncated: state.truncated, + truncation_reasons: [...state.truncationReasons].sort(), + }, + scripts: { + items, + observed_total: state.scriptsObserved, + excluded: { + ...exclusions, + invalid_protocol_value: state.invalidScripts, + }, + }, + execution_contexts: contexts, + directly_observed: [ + "Debugger.scriptParsed established script presence within the bounded capture window.", + "Runtime execution-context lifecycle events established context creation, destruction, or clearing.", + ], + unavailable_without_instrumentation: [ + "require/import caller-to-callee edges", + "EventEmitter emissions and listener invocation", + "Electron IPC messages and handlers", + "script unload events", + ], + unknowns: [ + "Scripts collected before attachment may have been garbage-collected and therefore omitted.", + "A bounded observation window cannot establish that an unobserved script or behavior never occurs.", + "The declared Node/Electron process role is not authenticated by the Inspector protocol.", + ], + limitations: describeInspectorTargetLimitations(), + }; +}; + +const scriptFromDraft = ( + draft: ScriptDraft, + location: JavaScriptRuntimeLocation, +): JavaScriptRuntimeObservation["scripts"]["items"][number] => { + const stable = JSON.stringify({ + location, + execution_context_key: draft.executionContextKey, + cdp_hash: draft.cdpHash, + length: draft.length, + is_module: draft.isModule, + }); + return { + script_key: `v8_script_${createHash("sha256").update(stable).digest("hex")}`, + location, + execution_context_key: draft.executionContextKey, + cdp_hash: draft.cdpHash, + length: draft.length, + is_module: draft.isModule, + status: "observed-loaded", + }; +}; diff --git a/src/browser/V8InspectorEndpoint.ts b/src/browser/V8InspectorEndpoint.ts new file mode 100644 index 000000000..3c8fd0b96 --- /dev/null +++ b/src/browser/V8InspectorEndpoint.ts @@ -0,0 +1,128 @@ +import { z } from "zod"; + +import type { + JavaScriptRuntimeTargetList, + JavaScriptRuntimeLocation, +} from "../domain/javascriptRuntimeObservation.js"; +import { + BrowserObservationError, + type BrowserObservationOperation, +} from "../domain/errors.js"; +import { parseCdpEndpointValue, readBoundedCdpJson } from "./CdpEndpoint.js"; + +const MAX_VERSION_BYTES = 64 * 1_024; +const MAX_TARGET_LIST_BYTES = 2 * 1_024 * 1_024; + +const versionSchema = z + .object({ + Browser: z.string().min(1).max(1_024), + "Protocol-Version": z.string().min(1).max(100), + "V8-Version": z.string().max(1_024).optional(), + }) + .passthrough(); + +const targetSchema = z + .object({ + id: z.string().min(1).max(256), + type: z.string().min(1).max(100), + url: z.string().max(65_536), + attached: z.boolean().default(false), + webSocketDebuggerUrl: z.string().min(1).max(2_048), + }) + .passthrough(); +const targetsSchema = z.array(targetSchema).max(1_000); + +export interface V8InspectorTarget { + readonly id: string; + readonly type: string; + readonly url: string; + readonly attached: boolean; + readonly webSocketUrl: string; +} + +export interface V8InspectorDiscovery { + readonly runtime: JavaScriptRuntimeTargetList["runtime"]; + readonly targets: readonly V8InspectorTarget[]; +} + +/** Discover bounded Node/Electron Inspector targets from one loopback endpoint. */ +export const discoverV8Inspector = async ( + endpoint: string, + operation: BrowserObservationOperation, + signal?: AbortSignal, +): Promise => { + const [versionInput, targetsInput] = await Promise.all([ + readBoundedCdpJson( + new URL("/json/version", endpoint), + MAX_VERSION_BYTES, + operation, + signal, + ), + readBoundedCdpJson( + new URL("/json/list", endpoint), + MAX_TARGET_LIST_BYTES, + operation, + signal, + ), + ]); + const version = parseCdpEndpointValue(versionSchema, versionInput, operation); + const targets = parseCdpEndpointValue(targetsSchema, targetsInput, operation); + return { + runtime: { + product: version.Browser, + protocol_version: version["Protocol-Version"], + v8_version: version["V8-Version"] ?? null, + }, + targets: targets.map((target) => ({ + id: target.id, + type: target.type, + url: target.url, + attached: target.attached, + webSocketUrl: validatedInspectorWebSocket( + endpoint, + target.id, + target.webSocketDebuggerUrl, + operation, + ), + })), + }; +}; + +const validatedInspectorWebSocket = ( + endpoint: string, + targetId: string, + reported: string, + operation: BrowserObservationOperation, +): string => { + let socket: URL; + try { + socket = new URL(reported); + } catch (cause: unknown) { + throw new BrowserObservationError(operation, "invalid_endpoint_response", { + cause, + }); + } + const trusted = new URL(endpoint); + const allowedPaths = new Set([ + `/${targetId}`, + `/devtools/node/${targetId}`, + `/devtools/page/${targetId}`, + ]); + if ( + socket.protocol !== "ws:" || + socket.port !== trusted.port || + socket.username !== "" || + socket.password !== "" || + socket.search !== "" || + socket.hash !== "" || + !allowedPaths.has(socket.pathname) + ) + throw new BrowserObservationError(operation, "invalid_endpoint_response"); + socket.hostname = trusted.hostname; + return socket.href; +}; + +/** Authorized target plus its durable location. */ +export interface AuthorizedV8InspectorTarget extends V8InspectorTarget { + readonly location: JavaScriptRuntimeLocation; +} diff --git a/src/browser/V8InspectorProvider.ts b/src/browser/V8InspectorProvider.ts new file mode 100644 index 000000000..19a05de40 --- /dev/null +++ b/src/browser/V8InspectorProvider.ts @@ -0,0 +1,439 @@ +import type { + ExecutionOptions, + ProviderIdentity, +} from "../application/AnalysisProvider.js"; +import type { JavaScriptRuntimeObservationPort } from "../application/JavaScriptRuntimeObservationPort.js"; +import { + javascriptRuntimeObservationSchema, + javascriptRuntimeTargetListSchema, + type JavaScriptRuntimeObservation, + type JavaScriptRuntimeTargetList, + type ListJavaScriptRuntimeTargetsInput, + type ObserveJavaScriptRuntimeInput, +} from "../domain/javascriptRuntimeObservation.js"; +import { + AnalysisError, + BrowserObservationError, + ProviderAdapterError, + type BrowserObservationOperation, +} from "../domain/errors.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { + boundedText, + numberValue, + recordValue, + stringValue, + delayWithCancellation, +} from "./CdpCaptureValues.js"; +import { CdpConnection, type CdpEvent } from "./CdpConnection.js"; +import { + authorizeRuntimeLocation, + canonicalRuntimeRoots, +} from "./JavaScriptRuntimeScope.js"; +import { + createInspectorExclusionCounts, + describeInspectorTargetLimitations, + finalizeInspectorCapture, +} from "./V8InspectorCaptureProjection.js"; +import { + discoverV8Inspector, + type AuthorizedV8InspectorTarget, + type V8InspectorTarget, +} from "./V8InspectorEndpoint.js"; + +/** Public identity committed by passive V8 Inspector observations. */ +export const V8_INSPECTOR_PROVIDER_IDENTITY: ProviderIdentity = Object.freeze({ + id: "rea-v8-inspector", + name: "REA passive Node/Electron V8 Inspector provider", + version: "1", +}); + +export interface ScriptDraft { + readonly rawUrl: string; + readonly executionContextKey: string | null; + readonly cdpHash: string | null; + readonly length: number; + readonly isModule: boolean; +} + +export interface ContextDraft { + readonly contextKey: string; + state: "created" | "destroyed" | "cleared"; + readonly origin: string | null; +} + +export interface CaptureState { + readonly scripts: ScriptDraft[]; + readonly contexts: Map; + eventsObserved: number; + eventsRetained: number; + eventsDropped: number; + metadataBytes: number; + scriptsObserved: number; + invalidScripts: number; + truncated: boolean; + readonly truncationReasons: Set; +} + +/** Attach-only provider; sends only Runtime.enable and Debugger.enable. */ +export class V8InspectorProvider implements JavaScriptRuntimeObservationPort { + identity(): ProviderIdentity { + return V8_INSPECTOR_PROVIDER_IDENTITY; + } + + async listTargets( + input: ListJavaScriptRuntimeTargetsInput, + options: ExecutionOptions = {}, + ): Promise> { + try { + const roots = await canonicalRuntimeRoots(input.allowed_file_roots); + const discovery = await discoverV8Inspector( + input.inspector_endpoint, + "list_javascript_runtime_targets", + options.signal, + ); + const allowed: AuthorizedV8InspectorTarget[] = []; + const excluded = createInspectorExclusionCounts(); + for (const target of discovery.targets) { + const decision = await authorizeRuntimeLocation( + target.url, + roots, + input.allowed_origins, + ); + if (!decision.allowed) { + excluded[decision.reason] += 1; + continue; + } + if (decision.location.kind === "builtin") { + excluded.unsupported_location += 1; + continue; + } + allowed.push({ ...target, location: decision.location }); + } + allowed.sort((left, right) => + left.id < right.id ? -1 : left.id > right.id ? 1 : 0, + ); + const items = allowed + .slice(input.offset, input.offset + input.limit) + .map(projectTarget); + const nextOffset = input.offset + items.length; + return ok( + javascriptRuntimeTargetListSchema.parse({ + schema_version: 1, + runtime: discovery.runtime, + targets: { + items, + offset: input.offset, + limit: input.limit, + total: allowed.length, + next_offset: nextOffset < allowed.length ? nextOffset : null, + has_more: nextOffset < allowed.length, + }, + excluded: { ...excluded, unconnectable: 0 }, + limitations: describeInspectorTargetLimitations(), + }), + ); + } catch (cause: unknown) { + return err(providerError(cause, "list_javascript_runtime_targets")); + } + } + + async observe( + input: ObserveJavaScriptRuntimeInput, + options: ExecutionOptions = {}, + ): Promise> { + let connection: CdpConnection | undefined; + try { + const roots = await canonicalRuntimeRoots(input.allowed_file_roots); + const discovery = await discoverV8Inspector( + input.inspector_endpoint, + "observe_javascript_runtime", + options.signal, + ); + const target = await authorizedTarget(discovery.targets, input, roots); + assertRuntimeKind(target, input.runtime_kind); + connection = await CdpConnection.connect( + target.webSocketUrl, + "observe_javascript_runtime", + options.signal, + ); + const state = emptyCaptureState(); + const removeListener = connection.onEvent((event) => + ingestEvent(event, input, state), + ); + try { + await connection.send("Runtime.enable", {}, undefined, options.signal); + await connection.send("Debugger.enable", {}, undefined, options.signal); + await waitForCapture(connection, input.observation_ms, options.signal); + } finally { + removeListener(); + } + const result = await finalizeInspectorCapture({ + input, + runtime: discovery.runtime, + target, + roots, + state, + }); + return ok(javascriptRuntimeObservationSchema.parse(result)); + } catch (cause: unknown) { + return err(providerError(cause, "observe_javascript_runtime")); + } finally { + await connection?.close(); + } + } +} + +const projectTarget = (target: AuthorizedV8InspectorTarget) => ({ + target_id: target.id, + protocol_type: target.type, + attached: target.attached, + location: target.location, +}); + +const authorizedTarget = async ( + targets: readonly V8InspectorTarget[], + input: ObserveJavaScriptRuntimeInput, + roots: readonly string[], +): Promise => { + const target = targets.find(({ id }) => id === input.target_id); + if (target === undefined) + throw new BrowserObservationError( + "observe_javascript_runtime", + "target_not_found", + ); + const decision = await authorizeRuntimeLocation( + target.url, + roots, + input.allowed_origins, + ); + if ( + target.attached || + !decision.allowed || + decision.location.kind === "builtin" + ) + throw new BrowserObservationError( + "observe_javascript_runtime", + "target_not_allowed", + ); + return { ...target, location: decision.location }; +}; + +const assertRuntimeKind = ( + target: AuthorizedV8InspectorTarget, + kind: ObserveJavaScriptRuntimeInput["runtime_kind"], +): void => { + const accepted = + kind === "electron-preload" || kind === "electron-renderer" + ? target.type === "page" + : target.type === "node"; + if (!accepted) + throw new BrowserObservationError( + "observe_javascript_runtime", + "target_not_allowed", + ); +}; + +const emptyCaptureState = (): CaptureState => ({ + scripts: [], + contexts: new Map(), + eventsObserved: 0, + eventsRetained: 0, + eventsDropped: 0, + metadataBytes: 0, + scriptsObserved: 0, + invalidScripts: 0, + truncated: false, + truncationReasons: new Set(), +}); + +const ingestEvent = ( + event: CdpEvent, + input: ObserveJavaScriptRuntimeInput, + state: CaptureState, +): void => { + if ( + event.method !== "Debugger.scriptParsed" && + event.method !== "Debugger.scriptFailedToParse" && + event.method !== "Runtime.executionContextCreated" && + event.method !== "Runtime.executionContextDestroyed" && + event.method !== "Runtime.executionContextsCleared" + ) + return; + state.eventsObserved += 1; + if (state.eventsObserved > input.limits.max_events) { + drop(state, "max_events"); + return; + } + if (event.method === "Debugger.scriptParsed") { + ingestScript(event, input, state); + return; + } + if (event.method === "Debugger.scriptFailedToParse") { + state.invalidScripts += 1; + retainEvent(state, 0, input); + return; + } + ingestContext(event, input, state); +}; + +const ingestScript = ( + event: CdpEvent, + input: ObserveJavaScriptRuntimeInput, + state: CaptureState, +): void => { + state.scriptsObserved += 1; + const value = recordValue(event.params); + const rawUrl = stringValue(value?.url); + if ( + rawUrl === undefined || + rawUrl === "" || + Buffer.byteLength(rawUrl) > input.limits.max_location_bytes + ) { + state.invalidScripts += 1; + retainEvent(state, 0, input); + return; + } + if (state.scripts.length >= input.limits.max_scripts) { + drop(state, "max_scripts"); + return; + } + const draft: ScriptDraft = { + rawUrl, + executionContextKey: contextKey(value?.executionContextId), + cdpHash: boundedText(value?.hash, 512), + length: nonnegativeInteger(value?.length), + isModule: value?.isModule === true, + }; + const bytes = metadataBytes(draft); + if (!retainEvent(state, bytes, input)) return; + state.scripts.push(draft); +}; + +const ingestContext = ( + event: CdpEvent, + input: ObserveJavaScriptRuntimeInput, + state: CaptureState, +): void => { + if (event.method === "Runtime.executionContextsCleared") { + for (const context of state.contexts.values()) context.state = "cleared"; + retainEvent(state, 0, input); + return; + } + const parameters = recordValue(event.params); + const runtimeContext = + event.method === "Runtime.executionContextCreated" + ? recordValue(parameters?.context) + : parameters; + const key = contextKey( + event.method === "Runtime.executionContextCreated" + ? runtimeContext?.id + : runtimeContext?.executionContextId, + ); + if (key === null) { + retainEvent(state, 0, input); + return; + } + if ( + state.contexts.size >= input.limits.max_execution_contexts && + !state.contexts.has(key) + ) { + drop(state, "max_execution_contexts"); + return; + } + const origin = + event.method === "Runtime.executionContextCreated" + ? boundedText(runtimeContext?.origin, 4_096) + : null; + const draft: ContextDraft = { + contextKey: key, + state: + event.method === "Runtime.executionContextCreated" + ? "created" + : "destroyed", + origin: + origin !== null && input.allowed_origins.includes(origin) ? origin : null, + }; + const bytes = metadataBytes(draft); + if (!retainEvent(state, bytes, input)) return; + state.contexts.set(key, draft); +}; + +const retainEvent = ( + state: CaptureState, + bytes: number, + input: ObserveJavaScriptRuntimeInput, +): boolean => { + if (state.metadataBytes + bytes > input.limits.max_total_metadata_bytes) { + drop(state, "max_total_metadata_bytes"); + return false; + } + state.metadataBytes += bytes; + state.eventsRetained += 1; + return true; +}; + +const drop = (state: CaptureState, reason: string): void => { + state.eventsDropped += 1; + state.truncated = true; + state.truncationReasons.add(reason); +}; + +const waitForCapture = async ( + connection: CdpConnection, + observationMs: number, + signal?: AbortSignal, +): Promise => { + let removeDisconnect = (): void => undefined; + const disconnected = new Promise((_resolve, reject) => { + removeDisconnect = connection.onDisconnect(() => + reject( + new BrowserObservationError( + "observe_javascript_runtime", + "disconnected", + ), + ), + ); + }); + try { + await Promise.race([ + delayWithCancellation( + observationMs, + "observe_javascript_runtime", + signal, + ), + disconnected, + ]); + } finally { + removeDisconnect(); + } +}; + +const metadataBytes = (value: object): number => + Buffer.byteLength(JSON.stringify(value)); + +const nonnegativeInteger = (value: unknown): number => { + const parsed = numberValue(value); + return parsed === undefined ? 0 : Math.max(0, Math.trunc(parsed)); +}; + +const contextKey = (value: unknown): string | null => { + const identifier = numberValue(value); + return identifier !== undefined && Number.isSafeInteger(identifier) + ? String(identifier) + : null; +}; + +const providerError = ( + cause: unknown, + operation: BrowserObservationOperation, +): AnalysisError => { + if (cause instanceof AnalysisError) return cause; + return new ProviderAdapterError( + V8_INSPECTOR_PROVIDER_IDENTITY.id, + operation, + { + cause, + }, + ); +}; diff --git a/src/catalogIdentity.ts b/src/catalogIdentity.ts index 2b45af05f..289cb5fda 100644 --- a/src/catalogIdentity.ts +++ b/src/catalogIdentity.ts @@ -66,6 +66,15 @@ export const MCP_RESOURCE_TEMPLATE_CATALOG = [ uri_template: "rea://reconstruction-coverage/{workspaceId}/revision/{revision}", }, + { + name: "reconstruction-obligation-ledger-page", + uri_template: + "rea://evidence/{evidenceId}/reconstruction-obligation-ledger", + }, + { + name: "reconstruction-readiness-report", + uri_template: "rea://evidence/{evidenceId}/reconstruction-readiness-report", + }, { name: "javascript-application-graph-page", uri_template: diff --git a/src/cli.ts b/src/cli.ts index d75d404a8..68ea8c3f1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -14,7 +14,9 @@ import { registerProcessCommands } from "./cliProcessCommands.js"; import { registerPolicyCommands } from "./cliPolicyCommands.js"; import { registerBrowserCommands } from "./cliBrowserCommands.js"; import { registerAdvancedBrowserCommands } from "./cliBrowserAdvancedCommands.js"; +import { registerBrowserScenarioCommands } from "./cliBrowserScenarioCommands.js"; import { registerElectronCommands } from "./cliElectronCommands.js"; +import { registerJavaScriptRuntimeObservationCommands } from "./cliJavaScriptRuntimeCommands.js"; import { registerApplicationCommands } from "./cliApplicationCommands.js"; import type { CliInstance } from "./cli/types.js"; @@ -60,7 +62,9 @@ export const createCli = (): CliInstance => { registerPolicyCommands(cli, logger); registerBrowserCommands(cli, logger); registerAdvancedBrowserCommands(cli, logger); + registerBrowserScenarioCommands(cli, logger); registerElectronCommands(cli, logger); + registerJavaScriptRuntimeObservationCommands(cli, logger); registerApplicationCommands(cli, logger); return cli; }; diff --git a/src/cli/artifactCommands.ts b/src/cli/artifactCommands.ts index b172e0b94..1704c5dcf 100644 --- a/src/cli/artifactCommands.ts +++ b/src/cli/artifactCommands.ts @@ -10,6 +10,12 @@ export const registerArtifactCommands = ( cli: CliInstance, logger: Logger, ): void => { + registerInventoryCommand(cli, logger); + registerInspectionCommand(cli, logger); + registerExtractionCommand(cli, logger); +}; + +const registerInventoryCommand = (cli: CliInstance, logger: Logger): void => { cli.command(CLI_COMMANDS.inventoryArtifact, { description: "Build a bounded deterministic artifact graph", args: z.object({ @@ -76,6 +82,9 @@ export const registerArtifactCommands = ( ), ), }); +}; + +const registerExtractionCommand = (cli: CliInstance, logger: Logger): void => { cli.command(CLI_COMMANDS.extractArtifact, { description: "Extract explicitly selected artifact occurrences safely", args: z.object({ @@ -103,3 +112,45 @@ export const registerArtifactCommands = ( ), }); }; + +const registerInspectionCommand = (cli: CliInstance, logger: Logger): void => { + cli.command(CLI_COMMANDS.inspectArtifact, { + description: + "Inspect one artifact with bounded observations and explicit next probes", + args: z.object({ + path: z.string().describe("Application or package path"), + }), + options: z.object({ + maxObservations: z + .number() + .int() + .min(1) + .max(500) + .default(100) + .describe("Maximum retained observations"), + maxRelationships: z + .number() + .int() + .min(1) + .max(500) + .default(100) + .describe("Maximum retained derived relationships"), + }), + alias: { + maxObservations: "max-observations", + maxRelationships: "max-relationships", + }, + run: ({ args, options }) => + logCliCommand(logger, "inspect-artifact", () => + runProviderAnalysis( + args.path, + "inspect_artifact", + { + max_observations: options.maxObservations, + max_relationships: options.maxRelationships, + }, + logger, + ), + ), + }); +}; diff --git a/src/cliApplicationCommands.ts b/src/cliApplicationCommands.ts index e94e1d3ed..a6e9aae22 100644 --- a/src/cliApplicationCommands.ts +++ b/src/cliApplicationCommands.ts @@ -3,12 +3,14 @@ import { Cli, z } from "incur"; import { compareApplicationVersionsEvidenceValidated, compareJavaScriptExportShapesEvidenceValidated, + compareSourceToBundleEvidenceValidated, traceApplicationFeatureEvidenceValidated, } from "./application/JavaScriptApplicationWorkflowService.js"; import { traceJavaScriptSemanticsEvidenceValidated } from "./application/JavaScriptSemanticTraceService.js"; import { resolveCompareApplicationVersionsRequest, resolveCompareJavaScriptExportShapesRequest, + resolveCompareSourceToBundleRequest, resolveTraceApplicationFeatureRequest, resolveTraceJavaScriptSemanticsRequest, } from "./application/ApplicationWorkflowEvidenceResolver.js"; @@ -25,6 +27,14 @@ import { reconstructionCoverageCommitInputSchema, reconstructionCoverageQueryInputSchema, } from "./application/ReconstructionCoverageService.js"; +import { + buildReconstructionObligationLedgerEvidenceValidated, + resolveReconstructionObligationLedgerRequest, +} from "./application/ReconstructionObligationLedgerService.js"; +import { + evaluateReconstructionReadinessValidated, + resolveReconstructionReadinessRequest, +} from "./application/ReconstructionReadinessService.js"; import { authorizeFileReadWithDeferredWrite, authorizeRootPermission, @@ -80,6 +90,15 @@ export const registerApplicationCommands = ( resolveInput: resolveCompareApplicationVersionsRequest, workflow: compareApplicationVersionsEvidenceValidated, }); + registerJsonCommand({ + cli, + logger, + name: CLI_COMMANDS.compareSourceToBundle, + description: + "Compare committed historical source with authenticated application Evidence", + resolveInput: resolveCompareSourceToBundleRequest, + workflow: compareSourceToBundleEvidenceValidated, + }); registerJsonCommand({ cli, logger, @@ -136,9 +155,36 @@ export const registerApplicationCommands = ( workflow: (config, authority, input) => executeNodeCharacterization(replayDependencies(config, authority), input), }); + registerObligationLedgerCommand(cli, logger); + registerReadinessCommand(cli, logger); registerCoverageCommands(cli, logger); }; +const registerObligationLedgerCommand = ( + cli: CliInstance, + logger: Logger, +): void => + registerJsonCommand({ + cli, + logger, + name: CLI_COMMANDS.buildReconstructionObligationLedger, + description: + "Generate a deterministic Evidence-backed reconstruction obligation ledger page", + resolveInput: resolveReconstructionObligationLedgerRequest, + workflow: buildReconstructionObligationLedgerEvidenceValidated, + }); + +const registerReadinessCommand = (cli: CliInstance, logger: Logger): void => + registerJsonCommand({ + cli, + logger, + name: CLI_COMMANDS.evaluateReconstructionReadiness, + description: + "Evaluate a complete Evidence-backed reconstruction readiness journey", + resolveInput: resolveReconstructionReadinessRequest, + workflow: evaluateReconstructionReadinessValidated, + }); + interface AuthorizedJsonCommandOptions { readonly cli: CliInstance; readonly logger: Logger; diff --git a/src/cliBrowserAdvancedCommands.ts b/src/cliBrowserAdvancedCommands.ts index 6a8132466..3004cf636 100644 --- a/src/cliBrowserAdvancedCommands.ts +++ b/src/cliBrowserAdvancedCommands.ts @@ -11,7 +11,7 @@ import { CdpBrowserProvider } from "./browser/CdpBrowserProvider.js"; import { logCliCommand } from "./cliLogging.js"; import { parseConfig } from "./config.js"; import { AnalysisInputError, projectAnalysisError } from "./domain/errors.js"; -import { compareWebCapturesInputSchema } from "./domain/webCaptureDiff.js"; +import { browserCaptureComparisonInputSchema } from "./domain/browserCaptureComparison.js"; import { discoverWebMcpToolsInputSchema } from "./domain/webMcpDiscovery.js"; import { captureWebScreenshotInputSchema, @@ -122,7 +122,8 @@ const registerCaptureDiff = ( logger: Logger, ): void => { cli.command(CLI_COMMANDS.compareWebCaptures, { - description: "Compare two normalized web capture JSON values", + description: + "Compare two normalized passive captures or recorded browser scenarios", args: z.object({ beforeJson: z.string().describe("Earlier normalized web capture JSON"), afterJson: z.string().describe("Later normalized web capture JSON"), @@ -135,16 +136,31 @@ const registerCaptureDiff = ( .max(20_000) .default(2_000) .describe("Maximum normalized changes to return"), + normalizationJson: z + .string() + .default('{"rules":[]}') + .describe( + "Recorded literal normalization policy for browser scenarios", + ), }), run: ({ args, options }) => logCliCommand(logger, "compare-web-captures", async () => { const before = parseJson(args.beforeJson); const after = parseJson(args.afterJson); - const parsed = compareWebCapturesInputSchema.safeParse({ - before, - after, - max_changes: options.maxChanges, - }); + const scenarioComparison = + browserCaptureComparisonInputSchema.safeParse({ + before_scenario: before, + after_scenario: after, + normalization: parseJson(options.normalizationJson), + max_changes: options.maxChanges, + }); + const parsed = scenarioComparison.success + ? scenarioComparison + : browserCaptureComparisonInputSchema.safeParse({ + before, + after, + max_changes: options.maxChanges, + }); if (!parsed.success) return inputError("compare_web_captures"); const result = await compareWebCaptureEvidence( new CdpBrowserProvider(), diff --git a/src/cliBrowserCommands.ts b/src/cliBrowserCommands.ts index 3d816d460..69c168a21 100644 --- a/src/cliBrowserCommands.ts +++ b/src/cliBrowserCommands.ts @@ -134,6 +134,7 @@ const registerPageInspection = ( websocket_shape_approved: options.websocketShapeApproved, include_script_sources: options.includeScriptSources, include_storage_keys: options.includeStorageKeys, + include_storage_fingerprints: options.includeStorageFingerprints, limits: { max_frames: options.maxFrames, max_dom_nodes: options.maxDomNodes, diff --git a/src/cliBrowserScenarioCommands.ts b/src/cliBrowserScenarioCommands.ts new file mode 100644 index 000000000..881dcfbc4 --- /dev/null +++ b/src/cliBrowserScenarioCommands.ts @@ -0,0 +1,57 @@ +import { Cli } from "incur"; +import { z } from "zod"; + +import { captureBrowserScenario } from "./application/BrowserScenarioCaptureService.js"; +import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js"; +import { PlaywrightBrowserScenarioProvider } from "./browser/PlaywrightBrowserScenarioProvider.js"; +import { CLI_COMMANDS } from "./cliCommandNames.js"; +import { parseCliJsonInput } from "./cliJsonInput.js"; +import { logCliCommand } from "./cliLogging.js"; +import { parseConfig } from "./config.js"; +import { AnalysisInputError, projectAnalysisError } from "./domain/errors.js"; +import { browserScenarioSchema } from "./domain/browserScenario.js"; +import type { JsonValue } from "./domain/jsonValue.js"; +import type { Logger } from "./logger.js"; + +const OPERATION = "capture_browser_scenario"; + +/** Register the one-shot controlled browser scenario command. */ +export const registerBrowserScenarioCommands = ( + cli: ReturnType, + logger: Logger, +): void => { + cli.command(CLI_COMMANDS.captureBrowserScenario, { + description: + "Run one approved, bounded Playwright browser scenario and return Evidence", + args: z.object({ + inputJson: z + .string() + .describe("Inline browser scenario JSON or JSON file path"), + }), + run: ({ args }) => + logCliCommand(logger, CLI_COMMANDS.captureBrowserScenario, async () => { + const input = await parseCliJsonInput(args.inputJson, OPERATION); + if (!input.ok) return input.error; + const scenario = browserScenarioSchema.safeParse(input.value); + if (!scenario.success) + return cliError(new AnalysisInputError(OPERATION)); + const config = parseConfig(process.env); + if (!config.ok) return cliError(config.error); + const authority = await loadConfiguredPermissionAuthority(config.value); + if (!authority.ok) return cliError(authority.error); + const result = await captureBrowserScenario( + new PlaywrightBrowserScenarioProvider(), + authority.value, + scenario.data, + ); + return result.ok ? result.value : cliError(result.error); + }), + }); +}; + +const cliError = ( + error: Parameters[0], +): JsonValue => ({ + error: "Browser scenario capture failed", + ...projectAnalysisError(error), +}); diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index 1f776ab79..06a069390 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -16,6 +16,7 @@ export const CLI_COMMANDS = Object.freeze({ search: "search", importReferenceSource: "import-reference-source", inventoryArtifact: "inventory-artifact", + inspectArtifact: "inspect-artifact", extractArtifact: "extract-artifact", inspectManagedArtifact: "inspect-managed-artifact", inspectManagedMembers: "inspect-managed-members", @@ -45,18 +46,24 @@ export const CLI_COMMANDS = Object.freeze({ compareWebCaptures: "compare-web-captures", captureWebScreenshot: "capture-web-screenshot", compareWebScreenshots: "compare-web-screenshots", + captureBrowserScenario: "capture-browser-scenario", listElectronTargets: "list-electron-targets", inspectElectronPage: "inspect-electron-page", + listJavaScriptRuntimeTargets: "list-javascript-runtime-targets", + observeJavaScriptRuntime: "observe-javascript-runtime", analyzeJavaScriptApplication: "analyze-javascript-application", reconcileJavaScriptRuntime: "reconcile-javascript-runtime", traceApplicationFeature: "trace-application-feature", traceJavaScriptSemantics: "trace-javascript-semantics", compareApplicationVersions: "compare-application-versions", + compareSourceToBundle: "compare-source-to-bundle", compareJavaScriptExportShapes: "compare-javascript-export-shapes", runReplayMachine: "run-replay-machine", runControlledReplay: "run-controlled-replay", prepareNodeCharacterization: "prepare-node-characterization", executeNodeCharacterization: "execute-node-characterization", + buildReconstructionObligationLedger: "build-reconstruction-obligation-ledger", + evaluateReconstructionReadiness: "evaluate-reconstruction-readiness", commitReconstructionCoverage: "commit-reconstruction-coverage", queryReconstructionCoverage: "query-reconstruction-coverage", }); diff --git a/src/cliJavaScriptRuntimeCommands.ts b/src/cliJavaScriptRuntimeCommands.ts new file mode 100644 index 000000000..032dee226 --- /dev/null +++ b/src/cliJavaScriptRuntimeCommands.ts @@ -0,0 +1,204 @@ +import { Cli, z } from "incur"; + +import { + listJavaScriptRuntimeTargets, + observeJavaScriptRuntime, +} from "./application/JavaScriptRuntimeObservationService.js"; +import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js"; +import { V8InspectorProvider } from "./browser/V8InspectorProvider.js"; +import { CLI_COMMANDS } from "./cliCommandNames.js"; +import { logCliCommand } from "./cliLogging.js"; +import { parseConfig } from "./config.js"; +import { + javascriptRuntimeKindSchema, + listJavaScriptRuntimeTargetsInputSchema, + observeJavaScriptRuntimeInputSchema, +} from "./domain/javascriptRuntimeObservation.js"; +import { AnalysisInputError, projectAnalysisError } from "./domain/errors.js"; +import type { JsonValue } from "./domain/jsonValue.js"; +import type { Logger } from "./logger.js"; + +const scopeOptions = { + allowedFileRoots: z + .array(z.string().min(1)) + .optional() + .describe("Exact roots; defaults to REA_V8_INSPECTOR_FILE_ROOTS_JSON"), + allowedOrigins: z + .array(z.string().min(1)) + .optional() + .describe( + "Exact origins; defaults to REA_V8_INSPECTOR_ALLOWED_ORIGINS_JSON", + ), + approved: z.boolean().default(false).describe("Approve passive attachment"), +}; + +const listOptionsSchema = z.object({ + ...scopeOptions, + offset: z + .number() + .int() + .min(0) + .default(0) + .describe("Zero-based target offset"), + limit: z + .number() + .int() + .min(1) + .max(200) + .default(100) + .describe("Maximum targets to return"), +}); + +const observeOptionsSchema = z.object({ + ...scopeOptions, + runtimeKind: javascriptRuntimeKindSchema.describe( + "Declared target role; Inspector cannot authenticate the Electron role", + ), + observationMs: z + .number() + .int() + .min(0) + .max(10_000) + .default(100) + .describe("Bounded observation window in milliseconds"), + maxEvents: z + .number() + .int() + .min(1) + .max(50_000) + .default(10_000) + .describe("Maximum retained protocol events"), + maxScripts: z + .number() + .int() + .min(1) + .max(10_000) + .default(2_000) + .describe("Maximum retained scripts"), + maxExecutionContexts: z + .number() + .int() + .min(1) + .max(5_000) + .default(1_000) + .describe("Maximum retained execution contexts"), + maxLocationBytes: z + .number() + .int() + .min(64) + .max(65_536) + .default(16_384) + .describe("Maximum bytes in one protocol location"), + maxTotalMetadataBytes: z + .number() + .int() + .min(1_024) + .max(32 * 1_024 * 1_024) + .default(4 * 1_024 * 1_024) + .describe("Maximum retained metadata bytes"), +}); + +/** Register CLI equivalents of passive V8 Inspector tools. */ +export const registerJavaScriptRuntimeObservationCommands = ( + cli: ReturnType, + logger: Logger, +): void => { + cli.command(CLI_COMMANDS.listJavaScriptRuntimeTargets, { + description: "List approved Node/Electron V8 Inspector targets", + args: z.object({ + endpoint: z.string().describe("Configured literal-loopback endpoint"), + }), + options: listOptionsSchema, + run: ({ args, options }) => + logCliCommand( + logger, + CLI_COMMANDS.listJavaScriptRuntimeTargets, + async () => { + const context = await runtimeContext(); + if (!context.ok) return context.error; + const parsed = listJavaScriptRuntimeTargetsInputSchema.safeParse({ + inspector_endpoint: args.endpoint, + allowed_file_roots: + options.allowedFileRoots ?? context.allowedFileRoots, + allowed_origins: options.allowedOrigins ?? context.allowedOrigins, + approved: options.approved, + offset: options.offset, + limit: options.limit, + }); + if (!parsed.success) + return inputError("list_javascript_runtime_targets"); + const result = await listJavaScriptRuntimeTargets( + context.provider, + context.authority, + parsed.data, + ); + return result.ok ? result.value : cliError(result.error); + }, + ), + }); + + cli.command(CLI_COMMANDS.observeJavaScriptRuntime, { + description: "Passively observe one exact Node/Electron Inspector target", + args: z.object({ + endpoint: z.string().describe("Configured literal-loopback endpoint"), + targetId: z + .string() + .describe("Target from list-javascript-runtime-targets"), + }), + options: observeOptionsSchema, + run: ({ args, options }) => + logCliCommand(logger, CLI_COMMANDS.observeJavaScriptRuntime, async () => { + const context = await runtimeContext(); + if (!context.ok) return context.error; + const parsed = observeJavaScriptRuntimeInputSchema.safeParse({ + inspector_endpoint: args.endpoint, + allowed_file_roots: + options.allowedFileRoots ?? context.allowedFileRoots, + allowed_origins: options.allowedOrigins ?? context.allowedOrigins, + target_id: args.targetId, + runtime_kind: options.runtimeKind, + approved: options.approved, + observation_ms: options.observationMs, + limits: { + max_events: options.maxEvents, + max_scripts: options.maxScripts, + max_execution_contexts: options.maxExecutionContexts, + max_location_bytes: options.maxLocationBytes, + max_total_metadata_bytes: options.maxTotalMetadataBytes, + }, + }); + if (!parsed.success) return inputError("observe_javascript_runtime"); + const result = await observeJavaScriptRuntime( + context.provider, + context.authority, + parsed.data, + ); + return result.ok ? result.value : cliError(result.error); + }), + }); +}; + +const runtimeContext = async () => { + const config = parseConfig(process.env); + if (!config.ok) return { ok: false as const, error: cliError(config.error) }; + const authority = await loadConfiguredPermissionAuthority(config.value); + if (!authority.ok) + return { ok: false as const, error: cliError(authority.error) }; + return { + ok: true as const, + authority: authority.value, + provider: new V8InspectorProvider(), + allowedFileRoots: config.value.v8InspectorFileRoots, + allowedOrigins: config.value.v8InspectorAllowedOrigins, + }; +}; + +const inputError = (operation: string): JsonValue => + cliError(new AnalysisInputError(operation)); + +const cliError = ( + error: Parameters[0], +): JsonValue => ({ + error: "JavaScript runtime observation failed", + ...projectAnalysisError(error), +}); diff --git a/src/cliObservationOptions.ts b/src/cliObservationOptions.ts index 99840900e..a1aa7e9a3 100644 --- a/src/cliObservationOptions.ts +++ b/src/cliObservationOptions.ts @@ -74,6 +74,10 @@ export const browserPageInspectionOptions = z.object({ .boolean() .default(false) .describe("Include storage key names without values"), + includeStorageFingerprints: z + .boolean() + .default(false) + .describe("Include content-derived storage fingerprints"), maxFrames: boundedCount("page frames", 1_000, 200), maxDomNodes: boundedCount("DOM nodes", 10_000, 2_000), maxAxNodes: boundedCount("accessibility nodes", 10_000, 2_000), diff --git a/src/cliPolicyCommands.ts b/src/cliPolicyCommands.ts index 2a279d3a4..b402ebc72 100644 --- a/src/cliPolicyCommands.ts +++ b/src/cliPolicyCommands.ts @@ -19,6 +19,7 @@ import { CLI_COMMANDS } from "./cliCommandNames.js"; const capabilitySchema = z.enum([ "process_capture", "browser_observe", + "browser_automate", "electron_observe", "evidence_read", "evidence_write", diff --git a/src/config/environment.ts b/src/config/environment.ts index 6a55ad069..bac4d11a9 100644 --- a/src/config/environment.ts +++ b/src/config/environment.ts @@ -54,9 +54,21 @@ const environmentSchema = z REA_BROWSER_OBSERVE_ENABLED: z.enum(["true", "false"]).default("false"), REA_BROWSER_CDP_ENDPOINTS_JSON: z.string().default("[]"), REA_BROWSER_ALLOWED_ORIGINS_JSON: z.string().default("[]"), + REA_BROWSER_SCENARIO_ENABLED: z.enum(["true", "false"]).default("false"), + REA_BROWSER_SCENARIO_AUTO_GRANT: z.enum(["true", "false"]).default("false"), + REA_BROWSER_SCENARIO_EXECUTABLE_ROOTS_JSON: z.string().default("[]"), + REA_BROWSER_SCENARIO_CDP_ENDPOINTS_JSON: z.string().default("[]"), + REA_BROWSER_SCENARIO_ALLOWED_ORIGINS_JSON: z.string().default("[]"), + REA_BROWSER_SCENARIO_ALLOWED_ENV_JSON: z.string().default("[]"), REA_ELECTRON_OBSERVE_ENABLED: z.enum(["true", "false"]).default("false"), REA_ELECTRON_CDP_ENDPOINTS_JSON: z.string().default("[]"), REA_ELECTRON_FILE_ROOTS_JSON: z.string().default("[]"), + REA_V8_INSPECTOR_OBSERVE_ENABLED: z + .enum(["true", "false"]) + .default("false"), + REA_V8_INSPECTOR_ENDPOINTS_JSON: z.string().default("[]"), + REA_V8_INSPECTOR_FILE_ROOTS_JSON: z.string().default("[]"), + REA_V8_INSPECTOR_ALLOWED_ORIGINS_JSON: z.string().default("[]"), REA_JAVASCRIPT_REPLAY_ENABLED: z.enum(["true", "false"]).default("false"), REA_JAVASCRIPT_REPLAY_ROOTS_JSON: z.string().default("[]"), REA_JAVASCRIPT_REPLAY_NODE_PATH: z diff --git a/src/config/parseConfig.ts b/src/config/parseConfig.ts index b4375ffe3..f88f19904 100644 --- a/src/config/parseConfig.ts +++ b/src/config/parseConfig.ts @@ -41,12 +41,34 @@ interface ParsedArrays { readonly secretPatterns: readonly string[]; readonly browserEndpoints: readonly string[]; readonly browserOrigins: readonly string[]; + readonly browserScenarioExecutableRoots: readonly string[]; + readonly browserScenarioEndpoints: readonly string[]; + readonly browserScenarioOrigins: readonly string[]; + readonly browserScenarioEnvironment: readonly string[]; readonly electronEndpoints: readonly string[]; readonly electronFileRoots: readonly string[]; + readonly v8InspectorEndpoints: readonly string[]; + readonly v8InspectorFileRoots: readonly string[]; + readonly v8InspectorOrigins: readonly string[]; readonly javascriptReplayRoots: readonly string[]; readonly managedRuntimeRoots: readonly string[]; } +type ParsedObservationArrays = Pick< + ParsedArrays, + | "browserEndpoints" + | "browserOrigins" + | "browserScenarioExecutableRoots" + | "browserScenarioEndpoints" + | "browserScenarioOrigins" + | "browserScenarioEnvironment" + | "electronEndpoints" + | "electronFileRoots" + | "v8InspectorEndpoints" + | "v8InspectorFileRoots" + | "v8InspectorOrigins" +>; + const parseAllArrays = ( env: Environment, ): Result => { @@ -92,31 +114,8 @@ const parseAllArrays = ( "REA_REFERENCE_SECRET_PATTERNS_JSON", ); if (!secretPatterns.ok) return secretPatterns; - const browserEndpoints = parseBrowserArray( - env.REA_BROWSER_CDP_ENDPOINTS_JSON, - "REA_BROWSER_CDP_ENDPOINTS_JSON", - browserEndpointSchema, - 16, - ); - if (!browserEndpoints.ok) return browserEndpoints; - const browserOrigins = parseBrowserArray( - env.REA_BROWSER_ALLOWED_ORIGINS_JSON, - "REA_BROWSER_ALLOWED_ORIGINS_JSON", - browserOriginSchema, - 32, - ); - if (!browserOrigins.ok) return browserOrigins; - const electronEndpoints = parseBrowserArray( - env.REA_ELECTRON_CDP_ENDPOINTS_JSON, - "REA_ELECTRON_CDP_ENDPOINTS_JSON", - browserEndpointSchema, - 16, - ); - if (!electronEndpoints.ok) return electronEndpoints; - const electronFileRoots = parseElectronFileRoots( - env.REA_ELECTRON_FILE_ROOTS_JSON, - ); - if (!electronFileRoots.ok) return electronFileRoots; + const observations = parseObservationArrays(env); + if (!observations.ok) return observations; const javascriptReplayRoots = parseAbsoluteRoots( env.REA_JAVASCRIPT_REPLAY_ROOTS_JSON, "REA_JAVASCRIPT_REPLAY_ROOTS_JSON", @@ -137,15 +136,98 @@ const parseAllArrays = ( analysisSnapshotRoots: analysisSnapshotRoots.value, referenceRoots: referenceRoots.value, secretPatterns: secretPatterns.value, - browserEndpoints: browserEndpoints.value, - browserOrigins: browserOrigins.value, - electronEndpoints: electronEndpoints.value, - electronFileRoots: electronFileRoots.value, + ...observations.value, javascriptReplayRoots: javascriptReplayRoots.value, managedRuntimeRoots: managedRuntimeRoots.value, }); }; +const parseObservationArrays = ( + env: Environment, +): Result => { + const browserEndpoints = parseBrowserArray( + env.REA_BROWSER_CDP_ENDPOINTS_JSON, + "REA_BROWSER_CDP_ENDPOINTS_JSON", + browserEndpointSchema, + 16, + ); + if (!browserEndpoints.ok) return browserEndpoints; + const browserOrigins = parseBrowserArray( + env.REA_BROWSER_ALLOWED_ORIGINS_JSON, + "REA_BROWSER_ALLOWED_ORIGINS_JSON", + browserOriginSchema, + 32, + ); + if (!browserOrigins.ok) return browserOrigins; + const browserScenarioExecutableRoots = parseAbsoluteRoots( + env.REA_BROWSER_SCENARIO_EXECUTABLE_ROOTS_JSON, + "REA_BROWSER_SCENARIO_EXECUTABLE_ROOTS_JSON", + ); + if (!browserScenarioExecutableRoots.ok) return browserScenarioExecutableRoots; + const browserScenarioEndpoints = parseBrowserArray( + env.REA_BROWSER_SCENARIO_CDP_ENDPOINTS_JSON, + "REA_BROWSER_SCENARIO_CDP_ENDPOINTS_JSON", + browserEndpointSchema, + 16, + ); + if (!browserScenarioEndpoints.ok) return browserScenarioEndpoints; + const browserScenarioOrigins = parseBrowserArray( + env.REA_BROWSER_SCENARIO_ALLOWED_ORIGINS_JSON, + "REA_BROWSER_SCENARIO_ALLOWED_ORIGINS_JSON", + browserOriginSchema, + 32, + ); + if (!browserScenarioOrigins.ok) return browserScenarioOrigins; + const browserScenarioEnvironment = parseStringArray( + env.REA_BROWSER_SCENARIO_ALLOWED_ENV_JSON, + "REA_BROWSER_SCENARIO_ALLOWED_ENV_JSON", + ); + if (!browserScenarioEnvironment.ok) return browserScenarioEnvironment; + const electronEndpoints = parseBrowserArray( + env.REA_ELECTRON_CDP_ENDPOINTS_JSON, + "REA_ELECTRON_CDP_ENDPOINTS_JSON", + browserEndpointSchema, + 16, + ); + if (!electronEndpoints.ok) return electronEndpoints; + const electronFileRoots = parseElectronFileRoots( + env.REA_ELECTRON_FILE_ROOTS_JSON, + ); + if (!electronFileRoots.ok) return electronFileRoots; + const v8InspectorEndpoints = parseBrowserArray( + env.REA_V8_INSPECTOR_ENDPOINTS_JSON, + "REA_V8_INSPECTOR_ENDPOINTS_JSON", + browserEndpointSchema, + 16, + ); + if (!v8InspectorEndpoints.ok) return v8InspectorEndpoints; + const v8InspectorFileRoots = parseAbsoluteRoots( + env.REA_V8_INSPECTOR_FILE_ROOTS_JSON, + "REA_V8_INSPECTOR_FILE_ROOTS_JSON", + ); + if (!v8InspectorFileRoots.ok) return v8InspectorFileRoots; + const v8InspectorOrigins = parseBrowserArray( + env.REA_V8_INSPECTOR_ALLOWED_ORIGINS_JSON, + "REA_V8_INSPECTOR_ALLOWED_ORIGINS_JSON", + browserOriginSchema, + 32, + ); + if (!v8InspectorOrigins.ok) return v8InspectorOrigins; + return ok({ + browserEndpoints: browserEndpoints.value, + browserOrigins: browserOrigins.value, + browserScenarioExecutableRoots: browserScenarioExecutableRoots.value, + browserScenarioEndpoints: browserScenarioEndpoints.value, + browserScenarioOrigins: browserScenarioOrigins.value, + browserScenarioEnvironment: browserScenarioEnvironment.value, + electronEndpoints: electronEndpoints.value, + electronFileRoots: electronFileRoots.value, + v8InspectorEndpoints: v8InspectorEndpoints.value, + v8InspectorFileRoots: v8InspectorFileRoots.value, + v8InspectorOrigins: v8InspectorOrigins.value, + }); +}; + const parseAbsoluteRoots = ( value: string, name: string, @@ -187,6 +269,25 @@ const appendBrowserObservationCeiling = ( ); }; +const appendBrowserScenarioCeiling = ( + ceilings: PermissionCeiling[], + env: Environment, + arrays: ParsedArrays, +): void => { + if (env.REA_BROWSER_SCENARIO_ENABLED !== "true") return; + ceilings.push( + permissionScope("browser_automate", [], { + executables: arrays.browserScenarioExecutableRoots, + environment_names: arrays.browserScenarioEnvironment, + origins: [ + ...arrays.browserScenarioEndpoints, + ...arrays.browserScenarioOrigins, + ], + network: browserNetworkScope(arrays.browserScenarioOrigins), + }), + ); +}; + const appendElectronObservationCeiling = ( ceilings: PermissionCeiling[], env: Environment, @@ -201,6 +302,20 @@ const appendElectronObservationCeiling = ( ); }; +const appendV8InspectorObservationCeiling = ( + ceilings: PermissionCeiling[], + env: Environment, + arrays: ParsedArrays, +): void => { + if (env.REA_V8_INSPECTOR_OBSERVE_ENABLED !== "true") return; + ceilings.push( + permissionScope("v8_inspector_observe", arrays.v8InspectorFileRoots, { + origins: [...arrays.v8InspectorEndpoints, ...arrays.v8InspectorOrigins], + network: "loopback", + }), + ); +}; + const appendNativeMountCeiling = ( ceilings: PermissionCeiling[], env: Environment, @@ -261,7 +376,9 @@ const buildPermissionCeilings = ( ]; appendProcessCaptureCeiling(ceilings, env, arrays); appendBrowserObservationCeiling(ceilings, env, arrays); + appendBrowserScenarioCeiling(ceilings, env, arrays); appendElectronObservationCeiling(ceilings, env, arrays); + appendV8InspectorObservationCeiling(ceilings, env, arrays); appendNativeMountCeiling(ceilings, env); appendJavaScriptReplayCeiling(ceilings, env, arrays); appendManagedRuntimeCeiling(ceilings, env, arrays); @@ -306,9 +423,21 @@ const buildAppConfig = ( browserObservationEnabled: env.REA_BROWSER_OBSERVE_ENABLED === "true", browserCdpEndpoints: arrays.browserEndpoints, browserAllowedOrigins: arrays.browserOrigins, + browserScenarioPolicy: { + enabled: env.REA_BROWSER_SCENARIO_ENABLED === "true", + executableRoots: arrays.browserScenarioExecutableRoots, + cdpEndpoints: arrays.browserScenarioEndpoints, + allowedOrigins: arrays.browserScenarioOrigins, + allowedEnvironment: arrays.browserScenarioEnvironment, + }, electronObservationEnabled: env.REA_ELECTRON_OBSERVE_ENABLED === "true", electronCdpEndpoints: arrays.electronEndpoints, electronFileRoots: arrays.electronFileRoots, + v8InspectorObservationEnabled: + env.REA_V8_INSPECTOR_OBSERVE_ENABLED === "true", + v8InspectorEndpoints: arrays.v8InspectorEndpoints, + v8InspectorFileRoots: arrays.v8InspectorFileRoots, + v8InspectorAllowedOrigins: arrays.v8InspectorOrigins, javascriptReplayPolicy: { enabled: env.REA_JAVASCRIPT_REPLAY_ENABLED === "true", roots: arrays.javascriptReplayRoots, @@ -325,11 +454,13 @@ const buildAppConfig = ( }, permissionCeilings, administratorPermissionGrants: administratorGrants( - env.REA_PROCESS_CAPTURE_AUTO_GRANT === "true" - ? permissionCeilings - : permissionCeilings.filter( - ({ capability }) => capability !== "process_capture", - ), + permissionCeilings.filter( + ({ capability }) => + (capability !== "process_capture" || + env.REA_PROCESS_CAPTURE_AUTO_GRANT === "true") && + (capability !== "browser_automate" || + env.REA_BROWSER_SCENARIO_AUTO_GRANT === "true"), + ), ), permissionProjectRoot: env.REA_PERMISSION_PROJECT_ROOT, permissionProjectStore: env.REA_PERMISSION_PROJECT_STORE, diff --git a/src/config/types.ts b/src/config/types.ts index 9731dd77c..2d99d04f7 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -28,9 +28,20 @@ export interface AppConfig { readonly browserObservationEnabled: boolean; readonly browserCdpEndpoints: readonly string[]; readonly browserAllowedOrigins: readonly string[]; + readonly browserScenarioPolicy: { + readonly enabled: boolean; + readonly executableRoots: readonly string[]; + readonly cdpEndpoints: readonly string[]; + readonly allowedOrigins: readonly string[]; + readonly allowedEnvironment: readonly string[]; + }; readonly electronObservationEnabled: boolean; readonly electronCdpEndpoints: readonly string[]; readonly electronFileRoots: readonly string[]; + readonly v8InspectorObservationEnabled: boolean; + readonly v8InspectorEndpoints: readonly string[]; + readonly v8InspectorFileRoots: readonly string[]; + readonly v8InspectorAllowedOrigins: readonly string[]; readonly javascriptReplayPolicy: { readonly enabled: boolean; readonly roots: readonly string[]; diff --git a/src/contracts/analysisSearchContract.ts b/src/contracts/analysisSearchContract.ts new file mode 100644 index 000000000..0d8d6b8a1 --- /dev/null +++ b/src/contracts/analysisSearchContract.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +/** Shared bounded provider search input fields. */ +export const analysisSearchInput = { + pattern: z + .string() + .min(1) + .max(256) + .describe("The literal text or bounded regex pattern to search for"), + mode: z.enum(["literal", "regex"]).default("literal"), + case_sensitive: z.boolean().default(false).describe("Whether to match case"), + offset: z.number().int().min(0).default(0), + limit: z.number().int().min(1).max(100).default(100), + document: z.string().optional().describe("The document name"), +}; diff --git a/src/contracts/applicationToolContracts.ts b/src/contracts/applicationToolContracts.ts index 2db523427..b02f3266c 100644 --- a/src/contracts/applicationToolContracts.ts +++ b/src/contracts/applicationToolContracts.ts @@ -1,10 +1,13 @@ import { applicationVersionComparisonResultSchema } from "../domain/javascriptApplicationVersionComparisonSchemas.js"; +import { z } from "zod"; import { applicationFeatureTraceResultSchema } from "../domain/javascriptFeatureTraceSchemas.js"; import { javaScriptSemanticTraceResultSchema } from "../domain/javascriptSemanticTraceSchemas.js"; import { javaScriptExportShapeComparisonResultSchema } from "../domain/javascriptExportShapeComparisonSchemas.js"; +import { sourceToBundleComparisonResultSchema } from "../domain/sourceToBundleComparisonSchemas.js"; import { compareApplicationVersionsRequestSchema, compareJavaScriptExportShapesRequestSchema, + compareSourceToBundleRequestSchema, traceApplicationFeatureRequestSchema, traceJavaScriptSemanticsRequestSchema, } from "./applicationWorkflowInputContracts.js"; @@ -24,6 +27,14 @@ import { reconstructionCoverageQueryInputSchema, } from "../application/ReconstructionCoverageService.js"; import { reconstructionClosureResultSchema } from "../domain/reconstructionCoverage.js"; +import { + reconstructionObligationLedgerInputSchema, + reconstructionObligationLedgerPageSchema, +} from "../domain/reconstructionObligationLedgerSchemas.js"; +import { + reconstructionReadinessInputSchema, + reconstructionReadinessReportSchema, +} from "../domain/reconstructionReadinessSchemas.js"; import { jsonObjectSchema } from "../domain/jsonValue.js"; import type { ToolContract } from "./toolContracts.js"; import { toolContractMetadata } from "./toolEffects.js"; @@ -32,7 +43,9 @@ import { JAVASCRIPT_APPLICATION_VERSION_COMPARISON_EXAMPLE, JAVASCRIPT_EXPORT_SHAPE_COMPARISON_EXAMPLE, JAVASCRIPT_FEATURE_TRACE_EXAMPLE, + SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE, } from "./javascriptApplicationWorkflowExamples.js"; +import { RECONSTRUCTION_READINESS_EXAMPLE } from "./reconstructionReadinessExample.js"; const traceOutputSchema = evidenceResultOf(applicationFeatureTraceResultSchema); const semanticTraceOutputSchema = evidenceResultOf( @@ -41,9 +54,35 @@ const semanticTraceOutputSchema = evidenceResultOf( const comparisonOutputSchema = evidenceResultOf( applicationVersionComparisonResultSchema, ); +const sourceToBundleOutputSchema = evidenceResultOf( + sourceToBundleComparisonResultSchema, +); const exportShapeComparisonOutputSchema = evidenceResultOf( javaScriptExportShapeComparisonResultSchema, ); +const reconstructionObligationLedgerOutputSchema = evidenceResultOf( + reconstructionObligationLedgerPageSchema, +); +const reconstructionReadinessOutputSchema = evidenceResultOf( + reconstructionReadinessReportSchema + .pick({ + schema: true, + schema_version: true, + report_id: true, + source_digest: true, + report_digest: true, + status: true, + summary: true, + metrics: true, + }) + .extend({ + report_resource_uri: z + .string() + .regex( + /^rea:\/\/evidence\/ev_[a-f0-9]{64}\/reconstruction-readiness-report$/u, + ), + }), +); const HASH = "0".repeat(64); const NODE_PREPARATION_EXAMPLE = jsonObjectSchema.parse({ preparation_approved: true, @@ -162,6 +201,22 @@ export const APPLICATION_TOOL_CONTRACTS = [ }, ], }, + { + name: "compare_source_to_bundle", + ...toolContractMetadata("compare_source_to_bundle"), + description: + "Compare a cryptographically committed HistoricalSourceGraph/v1 with authenticated JavaScript Application Graph Evidence supplied directly or by session Evidence ID. Uses explicit exact-digest, source-map path, current-path, suffix, and basename signals with stable weights. Classifies unchanged, modified, removed, split, merged, duplicated, and unknown; incomplete coverage and ambiguous weak signals never become absence or forced matches.", + kind: "application", + inputSchema: compareSourceToBundleRequestSchema, + outputSchema: sourceToBundleOutputSchema, + examples: [ + { + title: + "Compare committed historical source with a shipped bundle graph", + input: SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE, + }, + ], + }, { name: "compare_javascript_export_shapes", ...toolContractMetadata("compare_javascript_export_shapes"), @@ -264,6 +319,55 @@ export const APPLICATION_TOOL_CONTRACTS = [ }, ], }, + { + name: "build_reconstruction_obligation_ledger", + ...toolContractMetadata("build_reconstruction_obligation_ledger"), + description: + "Generate one deterministic page of a ReconstructionObligationLedger/v1 from an authenticated Evidence v2 bundle, reviewed obligations, and an explicit reconstruction manifest. Static candidates remain candidates; duplicate ownership, missing original or reconstruction cases, missing parser/type, weak verifier authority, unenumerated claims, contradictions, dependencies, and residual unknowns fail closed. Every page carries the same full-ledger closure digest and typed per-obligation diagnostics.", + kind: "application", + inputSchema: reconstructionObligationLedgerInputSchema, + outputSchema: reconstructionObligationLedgerOutputSchema, + examples: [ + { + title: "Generate the first obligation-ledger page", + input: { + evidence_bundle: { + bundle_version: 2, + artifacts: [], + providers: [], + environments: [], + scenarios: [], + captures: [], + unknowns: [], + records: [], + }, + reviewed_obligations: [], + manifest: { + schema_version: 1, + bindings: [], + contradictions: [], + }, + limits: { max_obligations: 1_000 }, + page: { offset: 0, limit: 50 }, + }, + }, + ], + }, + { + name: "evaluate_reconstruction_readiness", + ...toolContractMetadata("evaluate_reconstruction_readiness"), + description: + "Evaluate the fixed nine-stage public reconstruction journey into a deterministic ReconstructionReadinessReport/v1. Exact capability limits, provider routing, CLI/MCP status parity, bounded authority, partial-order comparison, contradictions, obligation closure, cleanup, and replay digests fail closed. Aggregate pass is emitted only when every required stage and check passes with attributable Evidence.", + kind: "application", + inputSchema: reconstructionReadinessInputSchema, + outputSchema: reconstructionReadinessOutputSchema, + examples: [ + { + title: "Evaluate the complete synthetic reconstruction journey", + input: RECONSTRUCTION_READINESS_EXAMPLE, + }, + ], + }, { name: "commit_reconstruction_coverage", ...toolContractMetadata("commit_reconstruction_coverage"), diff --git a/src/contracts/applicationWorkflowInputContracts.ts b/src/contracts/applicationWorkflowInputContracts.ts index 00457de46..3523eeef1 100644 --- a/src/contracts/applicationWorkflowInputContracts.ts +++ b/src/contracts/applicationWorkflowInputContracts.ts @@ -5,6 +5,7 @@ import { compareApplicationVersionsInputSchema } from "../domain/javascriptAppli import { compareJavaScriptExportShapesInputSchema } from "../domain/javascriptExportShapeComparisonSchemas.js"; import { traceApplicationFeatureInputSchema } from "../domain/javascriptFeatureTraceSchemas.js"; import { javaScriptSemanticQueryInputSchema } from "../domain/javascriptSemanticQuerySchemas.js"; +import { compareSourceToBundleInputSchema } from "../domain/sourceToBundleComparisonSchemas.js"; const evidenceIdSchema = z .string() @@ -102,6 +103,25 @@ export const compareApplicationVersionsRequestSchema = z ]); }); +/** Historical-source comparison accepting full application Evidence or a ledger reference. */ +export const compareSourceToBundleRequestSchema = z + .strictObject({ + reference: compareSourceToBundleInputSchema.shape.reference, + application: evidenceSchema.optional(), + application_evidence_id: evidenceIdSchema.optional(), + limits: compareSourceToBundleInputSchema.shape.limits, + unknown_registry_approved: + compareSourceToBundleInputSchema.shape.unknown_registry_approved, + }) + .superRefine((input, context) => { + requireExactlyOne(context, [ + "application", + input.application, + "application_evidence_id", + input.application_evidence_id, + ]); + }); + /** MCP/CLI export-shape request accepting full Evidence or ledger references. */ export const compareJavaScriptExportShapesRequestSchema = z .strictObject({ @@ -145,6 +165,9 @@ export type TraceJavaScriptSemanticsRequest = z.output< export type CompareApplicationVersionsRequest = z.output< typeof compareApplicationVersionsRequestSchema >; +export type CompareSourceToBundleRequest = z.output< + typeof compareSourceToBundleRequestSchema +>; export type CompareJavaScriptExportShapesRequest = z.output< typeof compareJavaScriptExportShapesRequestSchema >; diff --git a/src/contracts/artifactToolContracts.ts b/src/contracts/artifactToolContracts.ts index 7ada8bcdb..66ac62698 100644 --- a/src/contracts/artifactToolContracts.ts +++ b/src/contracts/artifactToolContracts.ts @@ -4,6 +4,7 @@ import type { ToolContract } from "./toolContracts.js"; import { artifactOutputSchemas } from "./toolOutputSchemas.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; import { toolContractMetadata } from "./toolEffects.js"; +import { artifactInspectionLimitsSchema } from "../domain/artifactInspection.js"; const pageInput = { node_offset: z.number().int().min(0).default(0), @@ -68,9 +69,32 @@ export const artifactExtractionInputSchema = z.object({ ...traversalLimitsInput, }); +/** Bounded provider-neutral inspection using one atomic inventory substep. */ +export const artifactInspectionInputSchema = z + .object({ + native_mount_approved: z.boolean().default(false), + integrity_policy: z.enum(["fail", "record-and-continue"]).default("fail"), + integrity_continue_approved: z.boolean().default(false), + max_integrity_mismatches: z.number().int().min(1).max(100).default(10), + ...artifactInspectionLimitsSchema.shape, + ...traversalLimitsInput, + }) + .superRefine((input, context) => { + if ( + input.integrity_policy === "record-and-continue" && + input.integrity_continue_approved !== true + ) + context.addIssue({ + code: "custom", + path: ["integrity_continue_approved"], + message: "record-and-continue requires explicit approval", + }); + }); + const exampleInputSchema = z.record(z.string(), jsonValueSchema); const examples: Readonly>>> = { inventory_artifact: {}, + inspect_artifact: {}, extract_artifact: { approved: true, output_root: "/tmp/rea-extracted", @@ -109,6 +133,11 @@ export const ARTIFACT_TOOL_CONTRACTS = [ "Inventory the active application or package as a deterministic, content-addressed artifact graph. Returns bounded node and edge pages without extracting or mounting by default.", artifactInventoryInputSchema, ), + artifact( + "inspect_artifact", + "Inspect the active artifact through one bounded, cancellable inventory substep. Returns the full substep Evidence and its ID, observations, derived relationships, hypotheses, contradictions, unexplored branches, limitations, and format-specific next probes. Any substep failure fails the whole call.", + artifactInspectionInputSchema, + ), artifact( "extract_artifact", "Extract selected graph artifacts beneath an explicit output root. Requires approval, rejects traversal and symlink escapes, never overwrites, enforces bomb limits, and verifies cleanup.", diff --git a/src/contracts/browserProviderToolContracts.ts b/src/contracts/browserProviderToolContracts.ts new file mode 100644 index 000000000..e6e674d45 --- /dev/null +++ b/src/contracts/browserProviderToolContracts.ts @@ -0,0 +1,8 @@ +import { BROWSER_SCENARIO_TOOL_CONTRACTS } from "./browserScenarioToolContracts.js"; +import { BROWSER_TOOL_CONTRACTS } from "./browserToolContracts.js"; + +/** Complete ordered browser-provider contract inventory. */ +export const BROWSER_PROVIDER_TOOL_CONTRACTS = [ + ...BROWSER_TOOL_CONTRACTS, + ...BROWSER_SCENARIO_TOOL_CONTRACTS, +] as const; diff --git a/src/contracts/browserScenarioToolContracts.ts b/src/contracts/browserScenarioToolContracts.ts new file mode 100644 index 000000000..4eb7749f3 --- /dev/null +++ b/src/contracts/browserScenarioToolContracts.ts @@ -0,0 +1,86 @@ +import type { ToolContract } from "./toolContracts.js"; +import { toolContractMetadata } from "./toolEffects.js"; +import { evidenceResultOf } from "./toolOutputSchemas.js"; +import { browserScenarioSchema } from "../domain/browserScenario.js"; +import { browserScenarioCaptureSchema } from "../domain/browserScenarioCapture.js"; +import type { JsonValue } from "../domain/jsonValue.js"; + +const example: Record = { + schema_version: 1, + browser: { + mode: "launch", + executable_path: "/opt/chromium/chrome", + headless: true, + user_data: "temporary-owned", + cleanup: "close-and-delete-profile", + }, + start_url: { url: "https://app.example.test/", query: [] }, + allowed_origins: ["https://app.example.test"], + environment: { + viewport: { width: 1_280, height: 720, device_scale_factor: 1 }, + locale: "en-US", + timezone: "UTC", + color_scheme: "light", + reduced_motion: "reduce", + service_workers: "block", + }, + actions: [ + { + step_id: "ready", + action: "wait_for", + locator: { kind: "test_id", value: "application-ready" }, + state: "visible", + }, + ], + storage: { cookies: [], local_storage: [], session_storage: [] }, + request_replay: { mode: "disabled" }, + secrets: [], + redaction: { + secret_values: "replace-with-secret-reference", + query_parameter_names: [], + header_names: [ + "authorization", + "cookie", + "proxy-authorization", + "set-cookie", + ], + }, + capture: { + after_each_step: ["screenshot", "url", "accessibility"], + at_end: ["dom", "history", "storage"], + events: ["console", "page-errors", "network", "websockets"], + }, + limits: { + max_duration_ms: 60_000, + action_timeout_ms: 5_000, + navigation_timeout_ms: 10_000, + max_events: 2_000, + max_frames: 100, + max_workers: 20, + max_popups: 10, + max_websockets: 100, + max_dom_nodes: 10_000, + max_accessibility_nodes: 10_000, + max_screenshots: 16, + max_screenshot_bytes: 4_194_304, + max_storage_entries: 256, + max_total_metadata_bytes: 4_194_304, + }, + approved: true, +}; + +/** Controlled browser scenario contract shared by MCP and catalog generation. */ +export const BROWSER_SCENARIO_TOOL_CONTRACTS = [ + { + name: "capture_browser_scenario", + ...toolContractMetadata("capture_browser_scenario"), + description: + "Run one approved, bounded browser scenario through Playwright. Launch mode owns and terminates a temporary browser profile; connect mode attaches to one exact loopback CDP target and disconnects without terminating the external browser. Only the fixed action vocabulary is accepted. Navigation, storage, requests, redirects, secrets, redaction, captures, and limits must be declared before execution. Returns step-indexed Evidence with screenshots, DOM, accessibility, URL/history/storage state, runtime events, explicit missing/truncated sections, and equality eligibility.", + kind: "browser-provider", + inputSchema: browserScenarioSchema, + outputSchema: evidenceResultOf(browserScenarioCaptureSchema), + examples: [ + { title: "Capture a deterministic browser scenario", input: example }, + ], + }, +] as const satisfies readonly ToolContract[]; diff --git a/src/contracts/browserToolContracts.ts b/src/contracts/browserToolContracts.ts index 7bf708b20..55fb44bbe 100644 --- a/src/contracts/browserToolContracts.ts +++ b/src/contracts/browserToolContracts.ts @@ -18,9 +18,9 @@ import { webMcpDiscoverySchema, } from "../domain/webMcpDiscovery.js"; import { - compareWebCapturesInputSchema, - webCaptureDiffSchema, -} from "../domain/webCaptureDiff.js"; + browserCaptureComparisonWireSchema, + browserCaptureComparisonSchema, +} from "../domain/browserCaptureComparison.js"; import { captureWebScreenshotInputSchema, compareWebScreenshotsInputSchema, @@ -38,12 +38,82 @@ const observationSessionOutputSchema = evidenceResult( webObservationSessionSchema, ); const webMcpOutputSchema = evidenceResult(webMcpDiscoverySchema); -const captureDiffOutputSchema = evidenceResult(webCaptureDiffSchema); +const captureDiffOutputSchema = evidenceResult(browserCaptureComparisonSchema); const screenshotOutputSchema = evidenceResult(webScreenshotSchema); const screenshotDiffOutputSchema = evidenceResult(webScreenshotDiffSchema); const endpoint = "http://127.0.0.1:9222"; const origin = "https://app.example.test"; +const scenarioUrl = { + url: `${origin}/`, + origin, + query_parameter_names: [], + redacted: false, +}; +const scenarioCompleteness = { + status: "complete", + equality_eligible: true, + missing_sections: [], + truncated_sections: [], +}; +const scenarioArtifacts = { + screenshot: { state: "not_requested" }, + dom: { state: "not_requested" }, + accessibility: { state: "not_requested" }, + url: { state: "not_requested" }, + history: { state: "not_requested" }, + storage: { state: "not_requested" }, +}; +const exampleScenarioCapture = () => ({ + schema_version: 1, + browser: { + mode: "connect", + process_ownership: "external", + cleanup: "disconnected-external", + product: "Chromium", + version: "149", + }, + scenario: { + start_origin: origin, + allowed_origins: [origin], + action_count: 1, + secret_references: [], + }, + duration_ms: 10, + steps: [ + { + step_index: 0, + step_id: "scenario_start", + action: "scenario_start", + status: "completed", + elapsed_ms: 0, + before_url: scenarioUrl, + after_url: scenarioUrl, + error: null, + event_sequence_start: 1, + event_sequence_end: 0, + artifacts: scenarioArtifacts, + completeness: scenarioCompleteness, + }, + { + step_index: 1, + step_id: "open-settings", + action: "click", + status: "completed", + elapsed_ms: 10, + before_url: scenarioUrl, + after_url: scenarioUrl, + error: null, + event_sequence_start: 1, + event_sequence_end: 0, + artifacts: scenarioArtifacts, + completeness: scenarioCompleteness, + }, + ], + events: { retained: 0, dropped: 0, items: [] }, + completeness: scenarioCompleteness, + limitations: [], +}); /** Origin-scoped, passive browser reverse-engineering contracts. */ export const BROWSER_TOOL_CONTRACTS = [ @@ -94,6 +164,7 @@ export const BROWSER_TOOL_CONTRACTS = [ websocket_shape_approved: false, include_script_sources: false, include_storage_keys: false, + include_storage_fingerprints: false, limits: { max_frames: 200, max_dom_nodes: 2_000, @@ -209,16 +280,26 @@ export const BROWSER_TOOL_CONTRACTS = [ name: "compare_web_captures", ...toolContractMetadata("compare_web_captures"), description: - "Compare two normalized web captures across DOM, scripts, resources, network, safe metadata, and optional WebMCP inventories. Stable observed changes are distinguished from unknown absence caused by incomplete capture coverage.", + "Compare either passive web captures or step-indexed browser scenarios. Scenario comparison aligns exact step IDs, records deterministic literal normalization, and exposes bounded artifact-level changes plus alignment failures. Missing or truncated evidence never proves equality.", kind: "browser-provider", - inputSchema: compareWebCapturesInputSchema, + inputSchema: browserCaptureComparisonWireSchema, outputSchema: captureDiffOutputSchema, examples: [ { - title: "Compare two normalized captures", + title: "Compare two recorded browser scenarios", input: { - before: { inspection: exampleInspection() }, - after: { inspection: exampleInspection() }, + before_scenario: exampleScenarioCapture(), + after_scenario: exampleScenarioCapture(), + normalization: { + rules: [ + { + rule_id: "volatile-build-id", + artifacts: ["dom", "accessibility"], + match: "build-123", + replacement: "[BUILD_ID]", + }, + ], + }, max_changes: 2_000, }, }, @@ -268,99 +349,6 @@ export const BROWSER_TOOL_CONTRACTS = [ }, ] as const satisfies readonly ToolContract[]; -function exampleCompleteness() { - return { - status: "complete_within_window", - conditions: ["complete_within_window"], - policy_filtered_sections: [], - attach_limited_sections: [], - truncated_sections: [], - unavailable_sections: [], - excluded: [], - dropped_events: { - scripts: 0, - network_requests: 0, - console_events: 0, - websocket_connections: 0, - websocket_frames: 0, - webmcp_tools: 0, - timeline_events: 0, - total: 0, - }, - }; -} - -function exampleInspection() { - return { - schema_version: 2, - browser: { - product: "Chrome/1", - protocol_version: "1.3", - revision: "example", - user_agent: "Chrome", - js_version: "1", - }, - target: { - target_id: "page-1", - type: "page", - title: "Example", - url: `${origin}/`, - origin, - attached: false, - }, - capture_window: { - started_at: "2026-07-14T00:00:00.000Z", - ended_at: "2026-07-14T00:00:01.000Z", - observation_ms: 1_000, - }, - completeness: exampleCompleteness(), - frames: [], - dom: { total_nodes: 0, nodes: [] }, - accessibility: { - total_nodes: 0, - text_capture: { - status: "not_approved", - retained_bytes: 0, - excluded_fields: 0, - truncated_fields: 0, - }, - nodes: [], - }, - scripts: { total: 0, items: [] }, - resources: [], - network: { - requests: [], - websocket_events: [], - coverage_started_at: "2026-07-14T00:00:00.000Z", - prior_activity_available: false, - }, - console: { - events: [], - coverage_started_at: "2026-07-14T00:00:00.000Z", - prior_activity_available: false, - }, - workers: [], - metadata: { - responses: [], - dom_urls: [], - agent_hints: [], - excluded_dom_urls: 0, - headers_allowlisted: true, - }, - storage: { - origin, - usage_bytes: null, - quota_bytes: null, - local_storage_keys: [], - session_storage_keys: [], - indexed_db_names: [], - cache_names: [], - values_redacted: true, - }, - limitations: [], - }; -} - function exampleScreenshot() { return { uri: "rea://web-screenshot/sha256/153cf6c9a526a63053a37b10234c2fd85df38887c2dc0a800d90abfa6631d01c", diff --git a/src/contracts/enhancedInputs.ts b/src/contracts/enhancedInputs.ts index 689f1be3c..3841b02a2 100644 --- a/src/contracts/enhancedInputs.ts +++ b/src/contracts/enhancedInputs.ts @@ -1,5 +1,16 @@ import { z } from "zod"; +const traceLiteralInputSchema = z.object({ + query: z.string().min(1), + case_sensitive: z.boolean().default(false), + limit: z.number().int().min(1).max(100).default(20), + max_operations: z.number().int().min(1).max(100).default(20), + unknown_registry_approved: z + .literal(true) + .optional() + .describe("Explicit approval to record bounded residuals durably"), +}); + /** Input schemas shared by MCP registration and enhanced application dispatch. */ export const enhancedInputSchemas = { swift_classes: z.object({ pattern: z.string().default("") }), @@ -52,11 +63,18 @@ export const enhancedInputSchemas = { basic_blocks: 0, }), }), - trace_feature: z.object({ - query: z.string().min(1), - case_sensitive: z.boolean().default(false), - limit: z.number().int().min(1).max(100).default(20), - max_operations: z.number().int().min(1).max(100).default(20), + trace_feature: traceLiteralInputSchema, + find_code_for_string: traceLiteralInputSchema, + trace_call_path: z.object({ + start: z.string().describe("A provider-normalized procedure address"), + goal: z + .string() + .describe("An optional provider-normalized destination address") + .optional(), + direction: z.enum(["forward", "backward"]).default("forward"), + max_depth: z.number().int().min(1).max(10).default(5), + max_nodes: z.number().int().min(1).max(500).default(100), + max_operations: z.number().int().min(1).max(500).default(100), unknown_registry_approved: z .literal(true) .optional() @@ -66,7 +84,7 @@ export const enhancedInputSchemas = { export type EnhancedToolName = keyof typeof enhancedInputSchemas; -/** Runtime parser for dispatching only the eight closed enhanced names. */ +/** Runtime parser for dispatching only the closed enhanced names. */ export const enhancedToolNameSchema = z.enum([ "swift_classes", "get_objc_classes", @@ -78,4 +96,6 @@ export const enhancedToolNameSchema = z.enum([ "binary_overview", "analyze_function", "trace_feature", + "find_code_for_string", + "trace_call_path", ]); diff --git a/src/contracts/hopperMemoryContracts.ts b/src/contracts/hopperMemoryContracts.ts new file mode 100644 index 000000000..0488ea5ac --- /dev/null +++ b/src/contracts/hopperMemoryContracts.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; + +const document = z.string().optional().describe("The document name"); +const address = z + .string() + .describe( + "A provider-normalized address; default memory uses 0x-prefixed hexadecimal", + ); + +/** Bounded Hopper memory and file-mapping contracts. */ +export const HOPPER_MEMORY_TOOL_DEFINITIONS = [ + { + name: "read_bytes", + description: + "Read at most 4,096 analyzed bytes from one provider-normalized virtual address. The hexadecimal payload reports the exact returned length; incomplete reads remain explicit and unsupported provider APIs return typed capability unavailability.", + inputSchema: z.object({ + address, + length: z.number().int().min(1).max(4_096).default(256), + document, + }), + }, + { + name: "address_to_file_offset", + description: + "Map one provider-normalized virtual address to its original nonnegative file offset. Unmapped addresses fail explicitly, and providers without an authoritative mapping API return typed capability unavailability.", + inputSchema: z.object({ address, document }), + }, +] as const; diff --git a/src/contracts/javascriptApplicationWorkflowExamples.ts b/src/contracts/javascriptApplicationWorkflowExamples.ts index 46872826f..bca29c1b1 100644 --- a/src/contracts/javascriptApplicationWorkflowExamples.ts +++ b/src/contracts/javascriptApplicationWorkflowExamples.ts @@ -4,6 +4,7 @@ import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE, JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE, } from "./javascriptRuntimeReconciliationExample.js"; +import { createHistoricalSourceGraph } from "../domain/referenceSourceGraph.js"; const reconciliation = reconcileJavaScriptRuntime( JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE, @@ -66,6 +67,48 @@ export const JAVASCRIPT_APPLICATION_VERSION_COMPARISON_EXAMPLE = { right_evidence_id: SECOND_EXAMPLE_EVIDENCE_ID, }; +/** Historical source inventory compared with one producer-returned graph ID. */ +export const SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE = { + reference: createHistoricalSourceGraph({ + schema: "HistoricalSourceGraph/v1", + authority: "historical-reference", + root_alias: "$REFERENCE_ROOT", + inventory_state: "complete", + entries: [ + { + path: "src", + kind: "directory", + classifications: ["source"], + tree_state: "enumerated", + limitations: [], + }, + { + path: "src/main.ts", + kind: "file", + sha256: "2".repeat(64), + size: 128, + language: "TypeScript", + classifications: ["source"], + content_state: "hashed", + limitations: [], + }, + ], + relationships: [], + parse_failures: [], + exclusions: [], + languages: ["TypeScript"], + manifests: [], + vcs: { kind: "none", head: null, dirty: null }, + provenance: { + importer: "rea", + importer_version: "1", + caller: "contract-example", + }, + limitations: [], + }), + application_evidence_id: EXAMPLE_EVIDENCE_ID, +}; + /** Exact static return-shape comparison using producer-returned Evidence IDs. */ export const JAVASCRIPT_EXPORT_SHAPE_COMPARISON_EXAMPLE = { left_evidence_id: EXAMPLE_EVIDENCE_ID, diff --git a/src/contracts/javascriptRuntimeObservationToolContracts.ts b/src/contracts/javascriptRuntimeObservationToolContracts.ts new file mode 100644 index 000000000..43e10d12f --- /dev/null +++ b/src/contracts/javascriptRuntimeObservationToolContracts.ts @@ -0,0 +1,68 @@ +import type { ToolContract } from "./toolContracts.js"; +import { toolContractMetadata } from "./toolEffects.js"; +import { evidenceResultOf } from "./toolOutputSchemas.js"; +import { + javascriptRuntimeObservationSchema, + javascriptRuntimeTargetListSchema, + listJavaScriptRuntimeTargetsInputSchema, + observeJavaScriptRuntimeInputSchema, +} from "../domain/javascriptRuntimeObservation.js"; + +const root = "/opt/example-app"; +const endpoint = "http://127.0.0.1:9229"; + +/** Passive Node/Electron V8 Inspector discovery and observation contracts. */ +export const JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS = [ + { + name: "list_javascript_runtime_targets", + ...toolContractMetadata("list_javascript_runtime_targets"), + description: + "List attachable Node/Electron V8 Inspector targets from one approved literal-loopback endpoint. Only targets inside exact canonical file roots or exact HTTP(S) origins are retained; excluded target locations never enter Evidence.", + kind: "runtime-provider", + inputSchema: listJavaScriptRuntimeTargetsInputSchema, + outputSchema: evidenceResultOf(javascriptRuntimeTargetListSchema), + examples: [ + { + title: "List approved Node Inspector targets", + input: { + inspector_endpoint: endpoint, + allowed_file_roots: [root], + allowed_origins: [], + approved: true, + offset: 0, + limit: 100, + }, + }, + ], + }, + { + name: "observe_javascript_runtime", + ...toolContractMetadata("observe_javascript_runtime"), + description: + "Attach passively to one exact approved Node/Electron V8 Inspector target and capture bounded Debugger.scriptParsed plus Runtime execution-context events. REA sends only Runtime.enable and Debugger.enable: it never evaluates, pauses, resumes, reads source, or instruments the target. require/import edges, EventEmitter activity, and Electron IPC remain explicit unknowns; reconcile the returned Evidence with static Application Graph Evidence using reconcile_javascript_runtime.", + kind: "runtime-provider", + inputSchema: observeJavaScriptRuntimeInputSchema, + outputSchema: evidenceResultOf(javascriptRuntimeObservationSchema), + examples: [ + { + title: "Observe one approved Node runtime", + input: { + inspector_endpoint: endpoint, + allowed_file_roots: [root], + allowed_origins: [], + target_id: "TARGET_ID_FROM_LIST_JAVASCRIPT_RUNTIME_TARGETS", + runtime_kind: "node", + approved: true, + observation_ms: 100, + limits: { + max_events: 10_000, + max_scripts: 2_000, + max_execution_contexts: 1_000, + max_location_bytes: 16_384, + max_total_metadata_bytes: 4_194_304, + }, + }, + }, + ], + }, +] as const satisfies readonly ToolContract[]; diff --git a/src/contracts/reconstructionReadinessExample.ts b/src/contracts/reconstructionReadinessExample.ts new file mode 100644 index 000000000..7780f27a2 --- /dev/null +++ b/src/contracts/reconstructionReadinessExample.ts @@ -0,0 +1,447 @@ +import { createEvidence } from "../domain/evidence.js"; +import { createEvidenceBundle } from "../domain/evidenceBundle.js"; +import { + READINESS_REQUIRED_CHECKS, + READINESS_STAGE_IDS, +} from "../domain/reconstructionReadinessJourneyFindings.js"; +import type { ReconstructionReadinessInput } from "../domain/reconstructionReadinessSchemas.js"; + +const HASH = "a".repeat(64); +const sourceEvidence = (variant: string) => + createEvidence( + undefined, + { + id: "rea-readiness-fixture", + name: "REA readiness fixture", + version: "1", + }, + { + predicateType: "rea.reconstruction-readiness-fixture/v1", + operation: "run_reconstruction_readiness_fixture", + parameters: { variant }, + result: { passed: true, variant }, + confidence: "observed", + authority: "controlled-replay", + }, + ); +const SOURCE = sourceEvidence("authority"); +const CONTRADICTING_SOURCE = sourceEvidence("contradiction"); +const EVIDENCE_ID = SOURCE.evidence_id; +const CASES = [ + "positive", + "negative", + "malformed", + "cancellation", + "teardown", +] as const; +const FIXTURES = [ + "native", + "javascript-cli", + "electron", + "incomplete-reconstruction", + "broken-runtime", +] as const; +const STATUSES = [ + "unowned", + "characterized", + "implemented", + "verified", + "contradicted", + "blocked", + "out-of-scope", + "unknown", +] as const; +const LAYERS = [ + "cli", + "protocol", + "electron", + "persistence", + "process", + "runtime", + "packaging", + "native-abi", + "application", + "other", +] as const; +const EVIDENCE_STATES = [ + "candidate", + "observed", + "reviewed", + "unknown", +] as const; + +/** Complete synthetic input shared by docs, tests, CLI, MCP, and verifier. */ +export const RECONSTRUCTION_READINESS_EXAMPLE: ReconstructionReadinessInput = { + schema_version: 1, + identity: { + cli_version: "2.4.0", + server_version: "2.4.0", + catalog_digest: HASH, + skill_digest: HASH, + versions: [ + { + component: "rea-cli", + version: "2.4.0", + digest: HASH, + state: "current", + remediation: null, + }, + { + component: "rea-server", + version: "2.3.0", + digest: "b".repeat(64), + state: "stale", + remediation: "Restart the connected MCP server.", + }, + { + component: "rea-server", + version: "2.4.0", + digest: HASH, + state: "current", + remediation: null, + }, + ], + providers: [ + { + provider_id: "javascript-application", + version: "1", + digest: HASH, + state: "available", + reason_code: null, + }, + { + provider_id: "broken-runtime-probe", + version: null, + digest: null, + state: "broken-host", + reason_code: "host-runtime-linker-failure", + }, + ], + }, + client: { + name: "synthetic-mcp-client", + version: "1", + negotiated_capabilities: ["elicitation", "progress", "resources"], + }, + capabilities: [ + { + capability_id: "analyze-javascript-application", + available: true, + bounded: true, + side_effect: "reads-target", + authority_scopes: ["investigation-input"], + limits: [{ name: "max-ast-nodes", value: 2_000_000, unit: "count" }], + unavailable_reason: null, + }, + ], + fixtures: FIXTURES.map((kind) => ({ + fixture_id: `fixture.${kind}`, + kind, + artifact_sha256: HASH, + environment_id: "environment.synthetic", + deliberate_faults: [`fault.${kind}`], + })), + workflow_candidates: FIXTURES.map((kind) => ({ + fixture_id: `fixture.${kind}`, + workflow_id: `workflow.${kind}`, + provider_id: kind === "broken-runtime" ? null : `provider.${kind}`, + selected: true, + invoked: true, + compatibility: "compatible", + reason_code: "compatible-artifact-family", + })), + grants: [ + { + grant_id: "grant.denied", + scope: "process-capture", + decision: "denied", + launched_process: false, + evidence_ids: [EVIDENCE_ID], + }, + { + grant_id: "grant.session", + scope: "process-capture", + decision: "granted", + launched_process: true, + evidence_ids: [EVIDENCE_ID], + }, + ], + evidence_bundle: createEvidenceBundle([SOURCE, CONTRADICTING_SOURCE]), + comparisons: [ + { + comparison_id: "comparison.concurrent", + verdict: "equivalent", + schedule_semantics: "partial-order", + concurrent: true, + truncated: false, + unavailable_authority: false, + unstable: false, + deliberate_divergence_ref: null, + divergence_refs: [], + contradiction_ids: [], + evidence_ids: [EVIDENCE_ID], + }, + { + comparison_id: "comparison.divergence", + verdict: "different", + schedule_semantics: "finite-traces", + concurrent: true, + truncated: false, + unavailable_authority: false, + unstable: false, + deliberate_divergence_ref: "/events/primary", + divergence_refs: ["/events/primary"], + contradiction_ids: [], + evidence_ids: [EVIDENCE_ID], + }, + { + comparison_id: "comparison.contradicted", + verdict: "unknown", + schedule_semantics: "finite-traces", + concurrent: false, + truncated: false, + unavailable_authority: false, + unstable: false, + deliberate_divergence_ref: null, + divergence_refs: [], + contradiction_ids: ["contradiction.artifact"], + evidence_ids: [EVIDENCE_ID], + }, + ], + contradictions: [ + { + contradiction_id: "contradiction.artifact", + declared_sha256: HASH, + observed_sha256: "b".repeat(64), + policy: "record-and-continue", + affected_comparison_ids: ["comparison.contradicted"], + evidence_ids: [EVIDENCE_ID, CONTRADICTING_SOURCE.evidence_id], + }, + ], + obligation_ledger: { + schema: "ReconstructionObligationLedger", + schema_version: 1, + ledger_id: `rol_${HASH}`, + closure_digest: HASH, + status: "ready", + coverage: { + status: "complete", + truncated: false, + omitted_count: 0, + max_obligations: 100, + }, + summary: { + total: 1, + required: 1, + verified: 1, + required_open: 0, + by_status: STATUSES.map((key) => ({ + key, + count: key === "verified" ? 1 : 0, + })), + by_application_layer: LAYERS.map((key) => ({ + key, + count: key === "process" ? 1 : 0, + })), + by_evidence_authority: EVIDENCE_STATES.map((key) => ({ + key, + count: key === "observed" ? 1 : 0, + })), + }, + reports: { + missing_owner_obligation_ids: [], + missing_verifier_obligation_ids: [], + contradicted_obligation_ids: [], + residual_unknown_ids: [], + }, + ownership_graph: [ + { + obligation_id: "obl.readiness", + module_path: "src/readiness.ts", + symbol: "runReadiness", + }, + ], + dependency_graph: [], + obligations: [ + { + obligation_id: "obl.readiness", + obligation_version: 1, + title: "Preserve the packaged readiness fixture lifecycle", + origin: "reviewed", + application_layer: "process", + family: "packaged-process-lifecycle", + target: { + artifact_sha256: HASH, + application_node_id: null, + semantic_node_id: null, + location: "/fixture", + }, + authority_references: [ + { + evidence_id: EVIDENCE_ID, + authority: "controlled-replay", + state: "observed", + location: "/fixture", + }, + ], + source_state: "observed", + observed_cases: CASES.map((kind) => ({ + kind, + evidence_id: EVIDENCE_ID, + location: `/original/${kind}`, + })), + required: true, + required_case_kinds: [...CASES], + required_original_authority: "process", + required_fixture_authority: "packaged-process", + required_verifier_authority: "packaged-process", + requires_parser_type: false, + dependency_obligation_ids: [], + residual_unknown_ids: [], + unavailable_authority: [], + required_next_evidence: [], + binding: { + obligation_id: "obl.readiness", + owner: { + module_path: "src/readiness.ts", + symbol: "runReadiness", + owner_sha256: HASH, + }, + parser_type: null, + original_cases: CASES.map((kind) => ({ + kind, + evidence_id: EVIDENCE_ID, + location: `/original/${kind}`, + })), + fixtures: CASES.map((kind) => ({ + fixture_id: `fixture.${kind}`, + case_kind: kind, + authority: "packaged-process", + evidence_ids: [EVIDENCE_ID], + })), + verifier: { + verifier_id: "verifier.readiness", + claim_id: "claim.readiness", + command: "npm run verify:readiness", + authority: "packaged-process", + status: "pass", + result_evidence_id: EVIDENCE_ID, + enumerated_obligation_ids: ["obl.readiness"], + nondeterminism: { + mode: "partial-order", + specification: "Startup precedes teardown.", + }, + }, + }, + status: "verified", + diagnostics: [], + }, + ], + evidence_links: [EVIDENCE_ID], + limitations: [], + }, + operation_outcomes: [ + { + sequence: 0, + operation: "inspect-artifact", + surface: "cli", + call_kind: "valid", + expected_success: true, + cli_exit_code: 0, + mcp_status: null, + error_code: null, + recovered: false, + evidence_ids: [EVIDENCE_ID], + }, + { + sequence: 1, + operation: "inspect-artifact", + surface: "mcp", + call_kind: "valid", + expected_success: true, + cli_exit_code: null, + mcp_status: "success", + error_code: null, + recovered: false, + evidence_ids: [EVIDENCE_ID], + }, + { + sequence: 2, + operation: "capture-process", + surface: "cli", + call_kind: "permission", + expected_success: false, + cli_exit_code: 2, + mcp_status: null, + error_code: "permission-required", + recovered: true, + evidence_ids: [EVIDENCE_ID], + }, + { + sequence: 3, + operation: "capture-process", + surface: "mcp", + call_kind: "permission", + expected_success: false, + cli_exit_code: null, + mcp_status: "non-success", + error_code: "permission-required", + recovered: true, + evidence_ids: [EVIDENCE_ID], + }, + ], + cleanup: [ + { + run_id: "run.cancelled", + cancelled: true, + owned_resources_remaining: 0, + diagnostic_evidence_ids: [EVIDENCE_ID], + }, + ], + closure_history: [ + { + sequence: 0, + ledger_digest: "b".repeat(64), + status: "open", + required_open: 1, + newly_verified_obligation_ids: [], + evidence_ids: [EVIDENCE_ID], + }, + { + sequence: 1, + ledger_digest: HASH, + status: "ready", + required_open: 0, + newly_verified_obligation_ids: ["obl.readiness"], + evidence_ids: [EVIDENCE_ID], + }, + ], + delegation_checks: [ + { + candidate_id: "candidate.reconstructed", + delegates_to_authority: false, + evidence_ids: [EVIDENCE_ID], + }, + ], + replay: { + expected_source_digest: null, + deterministic: true, + tamper_detected: true, + stale_input_detected: true, + evidence_ids: [EVIDENCE_ID], + }, + stages: READINESS_STAGE_IDS.map((stageId) => ({ + stage_id: stageId, + required: true, + status: "pass", + capability_issue: null, + next_action: null, + evidence_ids: [EVIDENCE_ID], + checks: READINESS_REQUIRED_CHECKS[stageId].map((checkId) => ({ + check_id: checkId, + status: "pass", + detail: `Synthetic conformance proved ${checkId}.`, + evidence_ids: [EVIDENCE_ID], + })), + })), +}; diff --git a/src/contracts/toolContractExamples.ts b/src/contracts/toolContractExamples.ts index c8ccd642c..6d84c8e5b 100644 --- a/src/contracts/toolContractExamples.ts +++ b/src/contracts/toolContractExamples.ts @@ -18,6 +18,8 @@ export const TOOL_EXAMPLE_OVERRIDES: Readonly< procedure_callers: { procedure: "main" }, procedure_info: { procedure: "main" }, read_function_instructions: { procedure: "main" }, + read_bytes: { address: "0x1000", length: 16 }, + address_to_file_offset: { address: "0x1000" }, procedure_references: { procedure: "main" }, procedure_pseudo_code: { procedure: "main" }, resolve_containing_procedure: { address: "0x1000" }, @@ -34,6 +36,8 @@ export const TOOL_EXAMPLE_OVERRIDES: Readonly< find_xrefs_to_name: { name: "malloc" }, analyze_function: { procedure: "main" }, trace_feature: { query: "license" }, + find_code_for_string: { query: "authorization failed" }, + trace_call_path: { start: "0x1000", goal: "0x1100" }, open_binary: { path: "/tmp/fixture" }, import_evidence_bundle: { path: "evidence.json" }, capture_process_scenario: { diff --git a/src/contracts/toolContractTypes.ts b/src/contracts/toolContractTypes.ts index 75446caf4..7b67e147c 100644 --- a/src/contracts/toolContractTypes.ts +++ b/src/contracts/toolContractTypes.ts @@ -26,6 +26,7 @@ export const TOOL_KINDS = [ "managed-provider", "browser-provider", "electron-provider", + "runtime-provider", "application", "session", ] as const; diff --git a/src/contracts/toolContracts.ts b/src/contracts/toolContracts.ts index 02843d0df..8fd5edefd 100644 --- a/src/contracts/toolContracts.ts +++ b/src/contracts/toolContracts.ts @@ -27,8 +27,9 @@ import { NATIVE_TOOL_CONTRACTS } from "./nativeToolContracts.js"; import { ARTIFACT_TOOL_CONTRACTS } from "./artifactToolContracts.js"; import { MANAGED_TOOL_CONTRACTS } from "./managedToolContracts.js"; import { MANAGED_WORKFLOW_TOOL_CONTRACTS } from "./managedWorkflowToolContracts.js"; -import { BROWSER_TOOL_CONTRACTS } from "./browserToolContracts.js"; +import { BROWSER_PROVIDER_TOOL_CONTRACTS } from "./browserProviderToolContracts.js"; import { ELECTRON_TOOL_CONTRACTS } from "./electronToolContracts.js"; +import { JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS } from "./javascriptRuntimeObservationToolContracts.js"; import { APPLICATION_TOOL_CONTRACTS } from "./applicationToolContracts.js"; import { closeBinaryInputSchema, @@ -38,6 +39,8 @@ import type { ToolContract, ToolExample } from "./toolContractTypes.js"; import { toolContractMetadata } from "./toolEffects.js"; import { binarySessionInputSchema } from "./sessionStatusContract.js"; import { functionInstructionInputSchema } from "./functionInstructionContract.js"; +import { HOPPER_MEMORY_TOOL_DEFINITIONS } from "./hopperMemoryContracts.js"; +import { analysisSearchInput } from "./analysisSearchContract.js"; export { binarySessionInputSchema } from "./sessionStatusContract.js"; @@ -51,27 +54,10 @@ const address = z ); const optionalAddress = address.optional(); const procedure = z.string().describe("The procedure name or address"); -const searchPattern = z - .string() - .min(1) - .max(256) - .describe("The literal text or bounded regex pattern to search for"); -const caseSensitive = z - .boolean() - .default(false) - .describe("Whether to match case"); const pagination = { offset: z.number().int().min(0).default(0), limit: z.number().int().min(1).max(500).default(100), }; -const searchInput = { - pattern: searchPattern, - mode: z.enum(["literal", "regex"]).default("literal"), - case_sensitive: caseSensitive, - offset: z.number().int().min(0).default(0), - limit: z.number().int().min(1).max(100).default(100), - document, -}; const exampleInputSchema = z.record(z.string(), jsonValueSchema); const examplesFor = ( @@ -246,6 +232,9 @@ export const OFFICIAL_TOOL_CONTRACTS = [ "Read one offset-paginated window of raw instructions for an analyzed procedure without decompilation, caller discovery, or whole-program string/name scans. Use this fast path for instruction-level orientation; follow next_offset when truncated.", functionInstructionInputSchema, ), + ...HOPPER_MEMORY_TOOL_DEFINITIONS.map(({ name, description, inputSchema }) => + official(name, description, inputSchema), + ), official( "procedure_references", "Return a bounded set of raw incoming or outgoing reference edges for one procedure. Endpoint procedures are resolved only from provider containment; Ghidra preserves observed reference kinds while providers without kind authority mark them unavailable.", @@ -271,12 +260,12 @@ export const OFFICIAL_TOOL_CONTRACTS = [ official( "search_procedures", "Search analyzed procedure names using literal matching by default or regex opt-in. Providers bound search work and reject regex constructs, paths, candidates, or cumulative work outside their finite budgets. Results are deterministic and offset-paginated.", - z.object(searchInput), + z.object(analysisSearchInput), ), official( "search_strings", "Search analyzed strings using literal matching by default or regex opt-in. Providers bound search work and reject regex constructs, paths, candidates, or cumulative work outside their finite budgets. Results are deterministic, offset-paginated, and explicitly truncated.", - z.object(searchInput), + z.object(analysisSearchInput), ), official( "set_address_name", @@ -375,6 +364,16 @@ export const ENHANCED_TOOL_CONTRACTS = [ "Trace a bounded literal feature query through matching strings and procedures, xrefs, and truthful containing-procedure resolution. Returns the operation budget, truncation, and residual unknowns; unknown_registry_approved: true records them durably without inferring reference kinds.", enhancedInputSchemas.trace_feature, ), + enhanced( + "find_code_for_string", + "Resolve one literal string query to bounded analyzed string entries, xrefs, and truthful containing-procedure candidates. Returns an Evidence ID, exact operation budget, truncation, and residual unknowns; it never infers reference kinds or runtime reachability.", + enhancedInputSchemas.find_code_for_string, + ), + enhanced( + "trace_call_path", + "Trace a deterministic bounded caller or callee path from one exact procedure address, optionally stopping at a goal. Returns visited nodes, direct-call edges, one shortest traversal path, failures, frontier, consumed limits, an Evidence ID, and explicit residual unknowns without claiming unresolved indirect calls are absent.", + enhancedInputSchemas.trace_call_path, + ), ] as const satisfies readonly ToolContract[]; /** Session-owned Evidence bundle export options. */ @@ -521,8 +520,9 @@ export const TOOL_CONTRACTS = [ ...ARTIFACT_TOOL_CONTRACTS, ...MANAGED_TOOL_CONTRACTS, ...MANAGED_WORKFLOW_TOOL_CONTRACTS, - ...BROWSER_TOOL_CONTRACTS, + ...BROWSER_PROVIDER_TOOL_CONTRACTS, ...ELECTRON_TOOL_CONTRACTS, + ...JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS, ...APPLICATION_TOOL_CONTRACTS, ...SESSION_TOOL_CONTRACTS, ] as const; diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index d4c31330f..81e29b6be 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -61,6 +61,8 @@ export const TOOL_EFFECTS: Readonly> = { procedure_callers: evidence, procedure_info: evidence, read_function_instructions: evidence, + read_bytes: evidence, + address_to_file_offset: evidence, procedure_references: evidence, procedure_pseudo_code: evidence, resolve_containing_procedure: evidence, @@ -88,12 +90,15 @@ export const TOOL_EFFECTS: Readonly> = { binary_overview: evidence, analyze_function: evidence, trace_feature: effects({ mutatesSession: true, idempotent: false }), + find_code_for_string: effects({ mutatesSession: true, idempotent: false }), + trace_call_path: effects({ mutatesSession: true, idempotent: false }), inspect_macho: nativeEvidence, inspect_signature: nativeEvidence, inspect_plist: nativeEvidence, list_architectures: nativeEvidence, demangle_swift: nativeEvidence, inventory_artifact: evidence, + inspect_artifact: evidence, extract_artifact: effects({ mutatesSession: true, writesFilesystem: true }), inspect_managed_artifact: evidence, inspect_managed_members: evidence, @@ -111,14 +116,29 @@ export const TOOL_EFFECTS: Readonly> = { compare_web_captures: browserEvidence, capture_web_screenshot: browserEvidence, compare_web_screenshots: browserEvidence, + capture_browser_scenario: effects({ + mutatesTarget: true, + mutatesSession: true, + writesFilesystem: true, + launchesProcess: true, + accessesNetwork: true, + changesUiState: true, + mayDiscardData: true, + idempotent: false, + }), list_electron_targets: browserEvidence, inspect_electron_page: browserEvidence, + list_javascript_runtime_targets: browserEvidence, + observe_javascript_runtime: browserEvidence, analyze_javascript_application: evidence, reconcile_javascript_runtime: evidence, trace_application_feature: evidence, trace_javascript_semantics: evidence, compare_application_versions: evidence, + compare_source_to_bundle: evidence, compare_javascript_export_shapes: evidence, + build_reconstruction_obligation_ledger: evidence, + evaluate_reconstruction_readiness: evidence, run_replay_machine: effects(), run_controlled_replay: effects({ mutatesSession: true, diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index 5c405fd2d..5b2d2582d 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -21,6 +21,7 @@ import { artifactExtractionResultSchema, artifactInventoryResultSchema, } from "../domain/artifactGraph.js"; +import { artifactInspectionResultSchema } from "../domain/artifactInspection.js"; import { managedArtifactInspectionSchema, managedMemberInspectionSchema, @@ -46,6 +47,7 @@ import { addressedEntry, bounded, containingProcedureResolution, + clientFeatureAvailabilitySchema, functionDossierOutput, graphNode, lifecycleResultOf, @@ -87,6 +89,21 @@ export const officialOutputSchemas: Readonly> = { procedure_callers: resultOf(addressList), procedure_info: procedureInfoOutput, read_function_instructions: resultOf(functionInstructionWindowSchema), + read_bytes: resultOf( + z.object({ + address: z.string(), + requested_bytes: z.number().int().min(1).max(4_096), + returned_bytes: z.number().int().min(0).max(4_096), + bytes_hex: z.string().regex(/^(?:[a-f0-9]{2})*$/u), + complete: z.boolean(), + }), + ), + address_to_file_offset: resultOf( + z.object({ + address: z.string(), + file_offset: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER), + }), + ), procedure_references: resultOf( z.object({ procedure: procedureIdentity, @@ -118,6 +135,71 @@ export const officialOutputSchemas: Readonly> = { xrefs: resultOf(addressList), }; +const literalTraceOutput = resultOf( + z.object({ + query: z.string(), + search_mode: z.literal("literal"), + operations_used: z.number().int().min(0), + operation_budget: z.number().int().min(1), + matches: z.array( + z.object({ + type: z.enum(["string", "procedure"]), + address: z.string(), + value: z.string(), + }), + ), + references: z.array( + z.object({ + target_address: z.string(), + source_address: z.string(), + containing_procedure: containingProcedureResolution, + }), + ), + truncated: z.boolean(), + residual_unknowns: z.array(z.string()), + }), +); + +const callPathTraceOutput = resultOf( + z.object({ + start: z.string(), + goal: z.string().nullable(), + direction: z.enum(["forward", "backward"]), + goal_status: z.enum(["not_requested", "reached", "not_reached"]), + nodes: z.array( + z.object({ + address: z.string(), + depth: z.number().int().min(0), + }), + ), + edges: z.array( + z.object({ + source_address: z.string(), + target_address: z.string(), + discovery_depth: z.number().int().min(1), + }), + ), + traversal_path: z.array(z.string()), + failures: z.array( + z.object({ + address: z.string(), + error: analysisErrorProjectionSchema, + }), + ), + frontier: z.array(z.string()), + limits: z.object({ + max_depth: z.number().int().min(1), + max_nodes: z.number().int().min(1), + max_operations: z.number().int().min(1), + nodes_visited: z.number().int().min(1), + operations_used: z.number().int().min(0), + }), + truncated: z.boolean(), + residual_unknowns: z.array(z.string()), + limitations: z.array(z.string()), + }), +); + /** Exact structured-content schemas for composed analysis workflows. */ export const enhancedOutputSchemas: Readonly> = { swift_classes: symbolDiscoveryOutput("classes"), @@ -190,30 +272,9 @@ export const enhancedOutputSchemas: Readonly> = { }), ), analyze_function: functionDossierOutput, - trace_feature: resultOf( - z.object({ - query: z.string(), - search_mode: z.literal("literal"), - operations_used: z.number().int().min(0), - operation_budget: z.number().int().min(1), - matches: z.array( - z.object({ - type: z.enum(["string", "procedure"]), - address: z.string(), - value: z.string(), - }), - ), - references: z.array( - z.object({ - target_address: z.string(), - source_address: z.string(), - containing_procedure: containingProcedureResolution, - }), - ), - truncated: z.boolean(), - residual_unknowns: z.array(z.string()), - }), - ), + trace_feature: literalTraceOutput, + find_code_for_string: literalTraceOutput, + trace_call_path: callPathTraceOutput, }; /** Exact Evidence v2 schemas for provider-neutral native inspection. */ @@ -228,6 +289,7 @@ export const nativeOutputSchemas: Readonly> = { /** Exact Evidence v2 schemas for provider-neutral artifact graph operations. */ export const artifactOutputSchemas: Readonly> = { inventory_artifact: resultOf(artifactInventoryResultSchema), + inspect_artifact: resultOf(artifactInspectionResultSchema), extract_artifact: resultOf(artifactExtractionResultSchema), }; @@ -319,6 +381,7 @@ export const sessionOutputSchemas: Readonly> = { next_cursor: z.number().int().min(0).nullable(), has_more: z.boolean(), }), + client_features: clientFeatureAvailabilitySchema, }), z.union([ sessionProvider.extend({ diff --git a/src/contracts/toolOutputSchemaPrimitives.ts b/src/contracts/toolOutputSchemaPrimitives.ts index a1165c6ae..f2be4f19f 100644 --- a/src/contracts/toolOutputSchemaPrimitives.ts +++ b/src/contracts/toolOutputSchemaPrimitives.ts @@ -207,12 +207,31 @@ const providerTargetSupport = z.discriminatedUnion("status", [ }), ]); +const clientFeatureNameSchema = z.enum([ + "elicitation_form", + "elicitation_url", + "roots", + "sampling", +]); + +/** Negotiated MCP client features that affect REA workflow availability. */ +export const clientFeatureAvailabilitySchema = z.object({ + elicitation_form: z.boolean(), + elicitation_url: z.boolean(), + roots: z.boolean(), + sampling: z.boolean(), +}); +export type ClientFeatureAvailability = z.infer< + typeof clientFeatureAvailabilitySchema +>; + export const toolAvailability = z.object({ name: z.string(), surface: z.string(), available: z.boolean(), reason: z.enum([ "available", + "client_capability_missing", "target_required", "provider_missing", "provider_unavailable", @@ -221,6 +240,12 @@ export const toolAvailability = z.object({ "policy_disabled", ]), remediation: z.string().nullable(), + client_requirements: z.object({ + required: z.array(clientFeatureNameSchema), + optional: z.array(clientFeatureNameSchema), + missing_required: z.array(clientFeatureNameSchema), + missing_optional: z.array(clientFeatureNameSchema), + }), effects: z.strictObject({ mutatesTarget: z.boolean(), mutatesSession: z.boolean(), diff --git a/src/domain/artifactInspection.ts b/src/domain/artifactInspection.ts new file mode 100644 index 000000000..89a84c90e --- /dev/null +++ b/src/domain/artifactInspection.ts @@ -0,0 +1,476 @@ +import { createHash } from "node:crypto"; + +import canonicalize from "canonicalize"; +import { z } from "zod"; + +import { + artifactInventoryResultSchema, + type ArtifactInventoryResult, +} from "./artifactGraph.js"; +import { evidenceSchema, parseEvidence, type Evidence } from "./evidence.js"; +import { jsonValueSchema } from "./jsonValue.js"; + +const boundedTextSchema = z.string().min(1).max(4_096); +const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u); +const artifactIdSchema = z.string().regex(/^art_[a-f0-9]{64}$/u); + +const inspectionLimitsSchema = z.strictObject({ + max_observations: z.number().int().min(1).max(500).default(100), + max_relationships: z.number().int().min(1).max(500).default(100), + max_hypotheses: z.number().int().min(1).max(100).default(20), + max_unexplored_branches: z.number().int().min(1).max(100).default(20), + max_next_probes: z.number().int().min(1).max(50).default(20), + max_substeps: z.literal(1).default(1), +}); + +export const artifactInspectionLimitsSchema = inspectionLimitsSchema; + +const inspectionObservationSchema = z.strictObject({ + observation_id: z.string().regex(/^aio_[a-f0-9]{64}$/u), + kind: z.enum(["root-manifest", "artifact", "occurrence", "integrity"]), + subject: boundedTextSchema, + value: jsonValueSchema, + evidence_id: evidenceIdSchema, +}); + +const inspectionRelationshipSchema = z.strictObject({ + relationship_id: z.string().regex(/^air_[a-f0-9]{64}$/u), + relation: z.enum([ + "contains", + "extracts", + "slice-of", + "embeds", + "loads", + "maps-source", + "derived-from", + ]), + source_artifact_id: artifactIdSchema, + target_artifact_id: artifactIdSchema, + occurrence_id: z.string().regex(/^occ_[a-f0-9]{64}$/u), + logical_path: boundedTextSchema.nullable(), + evidence_id: evidenceIdSchema, +}); + +const inspectionHypothesisSchema = z.strictObject({ + hypothesis_id: z.string().regex(/^aih_[a-f0-9]{64}$/u), + statement: boundedTextSchema, + confidence: z.enum(["medium", "low"]), + basis_evidence_ids: z.array(evidenceIdSchema).min(1).max(8), + limitation: boundedTextSchema, +}); + +const inspectionContradictionSchema = z.strictObject({ + contradiction_id: z.string().regex(/^aic_[a-f0-9]{64}$/u), + statement: boundedTextSchema, + occurrence_id: z.string().regex(/^occ_[a-f0-9]{64}$/u), + declared_sha256: z.string().regex(/^[a-f0-9]{64}$/u), + observed_sha256: z.string().regex(/^[a-f0-9]{64}$/u), + evidence_id: evidenceIdSchema, +}); + +const nextProbeSchema = z.strictObject({ + operation: z.string().min(1).max(128), + rationale: boundedTextSchema, + authority: z.enum([ + "artifact-bytes", + "managed-static-analysis", + "native-analysis-provider", + "ast-static-analysis", + ]), +}); + +const unexploredBranchSchema = z.strictObject({ + branch_id: z.string().regex(/^aib_[a-f0-9]{64}$/u), + reason: z.enum([ + "page-limit", + "format-specific-analysis-required", + "unknown-format", + ]), + detail: boundedTextSchema, + next_probe: nextProbeSchema.nullable(), + evidence_id: evidenceIdSchema, +}); + +/** Provider-neutral inspection result with its atomic source Evidence. */ +export const artifactInspectionResultSchema = z.strictObject({ + schema_version: z.literal(1), + inspection_id: z.string().regex(/^ai_[a-f0-9]{64}$/u), + subject: z.strictObject({ + manifest_id: z.string().regex(/^agm_[a-f0-9]{64}$/u), + root_artifact_id: artifactIdSchema, + root_sha256: z.string().regex(/^[a-f0-9]{64}$/u), + root_format: z.string().min(1).max(128), + }), + substeps: z + .array( + z.strictObject({ + substep_id: z.string().regex(/^ais_[a-f0-9]{64}$/u), + operation: z.literal("inventory_artifact"), + status: z.literal("completed"), + evidence_id: evidenceIdSchema, + evidence: evidenceSchema, + }), + ) + .min(1) + .max(1), + observations: z.array(inspectionObservationSchema).max(500), + derived_relationships: z.array(inspectionRelationshipSchema).max(500), + hypotheses: z.array(inspectionHypothesisSchema).max(100), + contradictions: z.array(inspectionContradictionSchema).max(100), + unexplored_branches: z.array(unexploredBranchSchema).max(100), + next_probes: z.array(nextProbeSchema).max(50), + coverage: z.strictObject({ + status: z.enum(["complete-within-substeps", "partial", "truncated"]), + substeps_completed: z.literal(1), + observations_retained: z.number().int().min(0), + observations_omitted: z.number().int().min(0), + relationships_retained: z.number().int().min(0), + relationships_omitted: z.number().int().min(0), + hypotheses_retained: z.number().int().min(0), + hypotheses_omitted: z.number().int().min(0), + unexplored_branches_retained: z.number().int().min(0), + unexplored_branches_omitted: z.number().int().min(0), + next_probes_retained: z.number().int().min(0), + next_probes_omitted: z.number().int().min(0), + }), + evidence_links: z.array(evidenceIdSchema).min(1).max(1), + limitations: z.array(boundedTextSchema).max(1_000), +}); + +export type ArtifactInspectionLimits = z.infer< + typeof artifactInspectionLimitsSchema +>; +export type ArtifactInspectionResult = z.infer< + typeof artifactInspectionResultSchema +>; + +/** Project one successful inventory Evidence record into an inspection report. */ +export const createArtifactInspection = ( + inventoryInput: unknown, + limits: ArtifactInspectionLimits, +): ArtifactInspectionResult => { + const inventoryEvidence = parseInventoryEvidence(inventoryInput); + const inventory = artifactInventoryResultSchema.parse( + inventoryEvidence.normalized_result, + ); + const observations = allObservations( + inventory, + inventoryEvidence.evidence_id, + ); + const relationships = allRelationships( + inventory, + inventoryEvidence.evidence_id, + ); + const hypotheses = allHypotheses(inventory, inventoryEvidence.evidence_id); + const nextProbes = probesFor(inventory); + const branches = allUnexploredBranches( + inventory, + inventoryEvidence.evidence_id, + nextProbes, + ); + const retained = { + observations: observations.slice(0, limits.max_observations), + relationships: relationships.slice(0, limits.max_relationships), + hypotheses: hypotheses.slice(0, limits.max_hypotheses), + branches: branches.slice(0, limits.max_unexplored_branches), + probes: nextProbes.slice(0, limits.max_next_probes), + }; + const omissions = { + observations: observations.length - retained.observations.length, + relationships: relationships.length - retained.relationships.length, + hypotheses: hypotheses.length - retained.hypotheses.length, + branches: branches.length - retained.branches.length, + probes: nextProbes.length - retained.probes.length, + }; + const semantic = { + schema_version: 1 as const, + subject: { + manifest_id: inventory.manifest.manifest_id, + root_artifact_id: inventory.manifest.root_artifact_id, + root_sha256: inventory.manifest.root_sha256, + root_format: inventory.manifest.root_format, + }, + substeps: [ + { + substep_id: `ais_${digestCanonical({ + operation: "inventory_artifact", + evidence_id: inventoryEvidence.evidence_id, + })}`, + operation: "inventory_artifact" as const, + status: "completed" as const, + evidence_id: inventoryEvidence.evidence_id, + evidence: inventoryEvidence, + }, + ], + observations: retained.observations, + derived_relationships: retained.relationships, + hypotheses: retained.hypotheses, + contradictions: contradictions(inventory, inventoryEvidence.evidence_id), + unexplored_branches: retained.branches, + next_probes: retained.probes, + coverage: { + status: inspectionCoverage(inventory, omissions), + substeps_completed: 1 as const, + observations_retained: retained.observations.length, + observations_omitted: omissions.observations, + relationships_retained: retained.relationships.length, + relationships_omitted: omissions.relationships, + hypotheses_retained: retained.hypotheses.length, + hypotheses_omitted: omissions.hypotheses, + unexplored_branches_retained: retained.branches.length, + unexplored_branches_omitted: omissions.branches, + next_probes_retained: retained.probes.length, + next_probes_omitted: omissions.probes, + }, + evidence_links: [inventoryEvidence.evidence_id], + limitations: uniqueSorted([ + ...inventory.limitations, + "Inspection derives bounded static hypotheses and next probes; it does not execute the artifact or claim format-specific semantics.", + ]), + }; + return artifactInspectionResultSchema.parse({ + ...semantic, + inspection_id: `ai_${digestCanonical(semantic)}`, + }); +}; + +const parseInventoryEvidence = (input: unknown): Evidence => { + const evidence = parseEvidence(input); + if (evidence.operation !== "inventory_artifact") + throw new TypeError( + "Artifact inspection substep requires inventory_artifact Evidence", + ); + return evidence; +}; + +const allObservations = ( + inventory: ArtifactInventoryResult, + evidenceId: string, +): ArtifactInspectionResult["observations"] => [ + observation( + "root-manifest", + inventory.manifest.root_artifact_id, + inventory.manifest, + evidenceId, + ), + ...inventory.nodes.items.map((node) => + observation("artifact", node.artifact_id, node, evidenceId), + ), + ...inventory.occurrences.items.map((occurrence) => + observation("occurrence", occurrence.occurrence_id, occurrence, evidenceId), + ), + ...inventory.integrity_contradictions.map((value) => + observation("integrity", value.contradiction_id, value, evidenceId), + ), +]; + +const observation = ( + kind: ArtifactInspectionResult["observations"][number]["kind"], + subject: string, + value: unknown, + evidenceId: string, +): ArtifactInspectionResult["observations"][number] => { + const semantic = { + kind, + subject, + value: jsonValueSchema.parse(value), + evidence_id: evidenceId, + }; + return { observation_id: `aio_${digestCanonical(semantic)}`, ...semantic }; +}; + +const allRelationships = ( + inventory: ArtifactInventoryResult, + evidenceId: string, +): ArtifactInspectionResult["derived_relationships"] => + inventory.edges.items.map((edge) => { + const semantic = { + relation: edge.relation, + source_artifact_id: edge.parent_artifact_id, + target_artifact_id: edge.child_artifact_id, + occurrence_id: edge.occurrence_id, + logical_path: edge.logical_path, + evidence_id: evidenceId, + }; + return { + relationship_id: `air_${digestCanonical(semantic)}`, + ...semantic, + }; + }); + +const allHypotheses = ( + inventory: ArtifactInventoryResult, + evidenceId: string, +): ArtifactInspectionResult["hypotheses"] => { + const statements = + inventory.manifest.root_format === "pe" + ? [ + "The PE artifact may contain managed CLI metadata or native code; inventory bytes alone do not distinguish them.", + ] + : ["asar", "javascript-bundle"].includes(inventory.manifest.root_format) + ? [ + "The artifact may contain a recoverable JavaScript application graph.", + ] + : []; + return statements.map((statement) => { + const semantic = { + statement, + confidence: "low" as const, + basis_evidence_ids: [evidenceId], + limitation: + "Format classification proposes a bounded follow-up; it is not semantic proof.", + }; + return { hypothesis_id: `aih_${digestCanonical(semantic)}`, ...semantic }; + }); +}; + +const contradictions = ( + inventory: ArtifactInventoryResult, + evidenceId: string, +): ArtifactInspectionResult["contradictions"] => + inventory.integrity_contradictions.map((value) => { + const semantic = { + statement: `Declared integrity for ${value.logical_path} contradicts observed bytes.`, + occurrence_id: value.occurrence_id, + declared_sha256: value.declared_sha256, + observed_sha256: value.observed_sha256, + evidence_id: evidenceId, + }; + return { + contradiction_id: `aic_${digestCanonical(semantic)}`, + ...semantic, + }; + }); + +const probesFor = ( + inventory: ArtifactInventoryResult, +): ArtifactInspectionResult["next_probes"] => { + const format = inventory.manifest.root_format; + if (["asar", "javascript-bundle", "directory"].includes(format)) + return [ + { + operation: "analyze_javascript_application", + rationale: + "Recover bounded JavaScript modules, source maps, Electron boundaries, and application relationships.", + authority: "ast-static-analysis", + }, + ]; + if (format === "pe") + return [ + { + operation: "inspect_managed_artifact", + rationale: + "Check execution-free CLI metadata before choosing a deep native provider.", + authority: "managed-static-analysis", + }, + { + operation: "open_binary", + rationale: + "Open as native code only when managed inspection reports a native or mixed boundary.", + authority: "native-analysis-provider", + }, + ]; + if (["mach-o", "mach-o-universal", "elf"].includes(format)) + return [ + { + operation: "open_binary", + rationale: + "Bind one deep native provider before function-level analysis.", + authority: "native-analysis-provider", + }, + ]; + if (format === "plist") + return [ + { + operation: "inspect_plist", + rationale: "Parse property-list values without executing the artifact.", + authority: "artifact-bytes", + }, + ]; + return []; +}; + +const allUnexploredBranches = ( + inventory: ArtifactInventoryResult, + evidenceId: string, + nextProbes: ArtifactInspectionResult["next_probes"], +): ArtifactInspectionResult["unexplored_branches"] => { + const pages = [ + ["nodes", inventory.nodes.next_offset], + ["occurrences", inventory.occurrences.next_offset], + ["relationships", inventory.edges.next_offset], + ] as const; + const branches = pages.flatMap(([collection, nextOffset]) => + nextOffset === null + ? [] + : [ + branch( + "page-limit", + `${collection} continue at offset ${String(nextOffset)}.`, + null, + evidenceId, + ), + ], + ); + branches.push( + ...nextProbes.map((probe) => + branch( + "format-specific-analysis-required", + `${probe.operation} was not run by provider-neutral artifact inspection.`, + probe, + evidenceId, + ), + ), + ); + if (inventory.manifest.root_format === "unknown") + branches.push( + branch( + "unknown-format", + "Artifact bytes did not match an admitted format classifier.", + null, + evidenceId, + ), + ); + return branches; +}; + +const branch = ( + reason: ArtifactInspectionResult["unexplored_branches"][number]["reason"], + detail: string, + nextProbe: ArtifactInspectionResult["next_probes"][number] | null, + evidenceId: string, +): ArtifactInspectionResult["unexplored_branches"][number] => { + const semantic = { + reason, + detail, + next_probe: nextProbe, + evidence_id: evidenceId, + }; + return { branch_id: `aib_${digestCanonical(semantic)}`, ...semantic }; +}; + +const inspectionCoverage = ( + inventory: ArtifactInventoryResult, + omissions: Readonly>, +): ArtifactInspectionResult["coverage"]["status"] => { + if (Object.values(omissions).some((count) => count > 0)) return "truncated"; + return [ + inventory.nodes.next_offset, + inventory.occurrences.next_offset, + inventory.edges.next_offset, + ].some((offset) => offset !== null) + ? "partial" + : "complete-within-substeps"; +}; + +const digestCanonical = (value: unknown): string => { + const encoded = canonicalize(value); + if (encoded === undefined) + throw new TypeError("Artifact inspection could not canonicalize"); + return createHash("sha256").update(encoded).digest("hex"); +}; + +const uniqueSorted = (values: readonly string[]): string[] => + [...new Set(values)].sort((left, right) => + left < right ? -1 : left > right ? 1 : 0, + ); diff --git a/src/domain/browserCaptureComparison.ts b/src/domain/browserCaptureComparison.ts new file mode 100644 index 000000000..fdfbb957b --- /dev/null +++ b/src/domain/browserCaptureComparison.ts @@ -0,0 +1,123 @@ +import { z } from "zod"; + +import { + browserScenarioDiffSchema, + compareBrowserScenarios, + compareBrowserScenariosInputSchema, + type BrowserScenarioDiff, + type CompareBrowserScenariosInput, +} from "./browserScenarioDiff.js"; +import { + compareWebCaptures, + captureSnapshotSchema, + webCaptureDiffSchema, + type CompareWebCapturesInput, + type WebCaptureDiff, +} from "./webCaptureDiff.js"; + +/** Top-level MCP shape for legacy page or step-indexed scenario comparison. */ +export const browserCaptureComparisonWireSchema = z + .strictObject({ + before: captureSnapshotSchema + .optional() + .describe("Earlier passive web-page capture; pair with after."), + after: captureSnapshotSchema + .optional() + .describe("Later passive web-page capture; pair with before."), + before_scenario: compareBrowserScenariosInputSchema.shape.before_scenario + .optional() + .describe("Earlier browser scenario capture; pair with after_scenario."), + after_scenario: compareBrowserScenariosInputSchema.shape.after_scenario + .optional() + .describe("Later browser scenario capture; pair with before_scenario."), + normalization: + compareBrowserScenariosInputSchema.shape.normalization.describe( + "Scenario-only exact-literal normalization policy.", + ), + max_changes: z + .number() + .int() + .min(1) + .max(20_000) + .default(2_000) + .describe("Maximum normalized change records to retain."), + }) + .superRefine((input, context) => { + const hasPassive = input.before !== undefined || input.after !== undefined; + const hasScenario = + input.before_scenario !== undefined || input.after_scenario !== undefined; + if (hasPassive === hasScenario) + context.addIssue({ + code: "custom", + message: + "Provide exactly one complete pair: before/after or before_scenario/after_scenario", + }); + if (hasPassive && (input.before === undefined || input.after === undefined)) + context.addIssue({ + code: "custom", + message: "Passive comparison requires both before and after", + }); + if ( + hasScenario && + (input.before_scenario === undefined || + input.after_scenario === undefined) + ) + context.addIssue({ + code: "custom", + message: + "Browser scenario comparison requires both before_scenario and after_scenario", + }); + if (hasPassive && input.normalization.rules.length > 0) + context.addIssue({ + code: "custom", + path: ["normalization"], + message: + "Literal normalization rules apply only to browser scenario captures", + }); + }); + +/** Parsed legacy page snapshots or step-indexed browser scenarios. */ +export const browserCaptureComparisonInputSchema = + browserCaptureComparisonWireSchema.transform((input) => { + if ( + input.before_scenario !== undefined && + input.after_scenario !== undefined + ) + return { + before_scenario: input.before_scenario, + after_scenario: input.after_scenario, + normalization: input.normalization, + max_changes: input.max_changes, + } satisfies CompareBrowserScenariosInput; + if (input.before !== undefined && input.after !== undefined) + return { + before: input.before, + after: input.after, + max_changes: input.max_changes, + } satisfies CompareWebCapturesInput; + throw new TypeError("Capture comparison pair refinement failed"); + }); +/** Parsed browser capture comparison input. */ +export type BrowserCaptureComparisonInput = z.output< + typeof browserCaptureComparisonInputSchema +>; + +/** Result from passive page or browser scenario comparison. */ +export const browserCaptureComparisonSchema = z.union([ + browserScenarioDiffSchema, + webCaptureDiffSchema, +]); +/** Browser capture comparison result. */ +export type BrowserCaptureComparison = BrowserScenarioDiff | WebCaptureDiff; + +/** Dispatch a parsed capture comparison to its pure domain comparator. */ +export const compareBrowserCaptures = ( + input: BrowserCaptureComparisonInput, +): BrowserCaptureComparison => + isScenarioComparison(input) + ? compareBrowserScenarios(input) + : compareWebCaptures(input); + +const isScenarioComparison = ( + input: BrowserCaptureComparisonInput, +): input is CompareBrowserScenariosInput => "before_scenario" in input; diff --git a/src/domain/browserObservation.ts b/src/domain/browserObservation.ts index 15f38e440..406dd86f6 100644 --- a/src/domain/browserObservation.ts +++ b/src/domain/browserObservation.ts @@ -222,11 +222,18 @@ export const inspectWebPageInputSchema = z websocket_shape_approved: z.boolean().default(false), include_script_sources: z.boolean().default(false), include_storage_keys: z.boolean().default(false), + include_storage_fingerprints: z.boolean().default(false), limits: browserInspectionLimitsSchema.default( DEFAULT_BROWSER_INSPECTION_LIMITS, ), }) .superRefine((input, context) => { + if (input.include_storage_fingerprints && !input.include_storage_keys) + context.addIssue({ + code: "custom", + path: ["include_storage_fingerprints"], + message: "Storage fingerprints require storage key capture", + }); if ( input.limits.max_script_source_bytes > input.limits.max_total_script_source_bytes @@ -303,7 +310,7 @@ export type ListBrowserTargetsInput = z.infer< >; export type InspectWebPageInput = z.infer; -const sanitizedBrowserUrlSchema = z.object({ +export const sanitizedBrowserUrlSchema = z.object({ url: z.string().max(MAX_SANITIZED_BROWSER_URL_CHARS), origin: z.string().max(2_048).nullable(), query_parameter_names: z diff --git a/src/domain/browserObservationErrors.ts b/src/domain/browserObservationErrors.ts new file mode 100644 index 000000000..e9e59327e --- /dev/null +++ b/src/domain/browserObservationErrors.ts @@ -0,0 +1,28 @@ +/** Public browser, Electron, Inspector, and scenario operations. */ +export type BrowserObservationOperation = + | "list_browser_targets" + | "inspect_web_page" + | "analyze_web_bundle" + | "observe_web_session" + | "discover_webmcp_tools" + | "compare_web_captures" + | "capture_web_screenshot" + | "compare_web_screenshots" + | "list_electron_targets" + | "inspect_electron_page" + | "list_javascript_runtime_targets" + | "observe_javascript_runtime" + | "capture_browser_scenario"; + +/** Stable failure reasons shared by browser-family provider adapters. */ +export type BrowserObservationFailureReason = + | "endpoint_unreachable" + | "invalid_endpoint_response" + | "target_not_found" + | "target_not_allowed" + | "target_changed" + | "protocol_error" + | "disconnected" + | "payload_limit" + | "cancelled" + | "secret_unavailable"; diff --git a/src/domain/browserObservationSchemas.ts b/src/domain/browserObservationSchemas.ts index c90a9922a..18a5f047c 100644 --- a/src/domain/browserObservationSchemas.ts +++ b/src/domain/browserObservationSchemas.ts @@ -63,6 +63,14 @@ const browserAxNodeSchema = z.object({ name: z.string().nullable(), description: z.string().nullable(), ignored: z.boolean(), + states: z + .array( + z.object({ + name: z.string(), + value: z.union([z.boolean(), z.number(), z.string()]), + }), + ) + .default([]), }); const browserScriptSourceSchema = z.discriminatedUnion("included", [ z.object({ included: z.literal(false), reason: z.string() }), @@ -295,6 +303,28 @@ export const webPageInspectionSchema = z.object({ session_storage_keys: z.array(z.string()), indexed_db_names: z.array(z.string()), cache_names: z.array(z.string()), + content_fingerprints: z + .array( + z.object({ + scope: z.enum([ + "cookie", + "local_storage", + "session_storage", + "indexed_db_schema", + "indexed_db_record", + "cache_entry", + ]), + identity_sha256: z.string().regex(/^[a-f0-9]{64}$/u), + value_sha256: z + .string() + .regex(/^[a-f0-9]{64}$/u) + .nullable(), + complete: z.boolean(), + }), + ) + .default([]), + fingerprint_algorithm: z.literal("sha256").default("sha256"), + fingerprints_complete: z.boolean().default(false), values_redacted: z.literal(true), }), limitations: z.array(z.string()), diff --git a/src/domain/browserScenario.ts b/src/domain/browserScenario.ts new file mode 100644 index 000000000..9d5c06d45 --- /dev/null +++ b/src/domain/browserScenario.ts @@ -0,0 +1,344 @@ +import { z } from "zod"; + +import { + BROWSER_SCENARIO_LIMITS, + browserScenarioActionSchema, + browserScenarioAllowedOriginsSchema, + browserScenarioBrowserSchema, + browserScenarioCaptureLimitsSchema, + browserScenarioCaptureSchema, + browserScenarioEnvironmentSchema, + browserScenarioRedactionSchema, + browserScenarioRequestReplaySchema, + browserScenarioSecretSchema, + browserScenarioStorageSchema, + browserScenarioUrlSchema, + type BrowserScenarioAction, + type BrowserScenarioUrl, + type BrowserScenarioValue, +} from "./browserScenarioValues.js"; + +export { + BROWSER_SCENARIO_LIMITS, + browserScenarioActionSchema, + browserScenarioUrlSchema, + browserScenarioValueSchema, + type BrowserScenarioAction, + type BrowserScenarioUrl, + type BrowserScenarioValue, +} from "./browserScenarioValues.js"; + +const scenarioShapeSchema = z.strictObject({ + schema_version: z.literal(1), + browser: browserScenarioBrowserSchema, + start_url: browserScenarioUrlSchema, + allowed_origins: browserScenarioAllowedOriginsSchema, + environment: browserScenarioEnvironmentSchema, + actions: z + .array(browserScenarioActionSchema) + .min(1) + .max(BROWSER_SCENARIO_LIMITS.actions), + storage: browserScenarioStorageSchema, + request_replay: browserScenarioRequestReplaySchema, + secrets: z + .array(browserScenarioSecretSchema) + .max(BROWSER_SCENARIO_LIMITS.secrets) + .default([]), + redaction: browserScenarioRedactionSchema, + capture: browserScenarioCaptureSchema, + limits: browserScenarioCaptureLimitsSchema, + approved: z.literal(true), +}); + +type ScenarioShape = z.infer; + +const addIssue = ( + context: z.RefinementCtx, + path: PropertyKey[], + message: string, +): void => context.addIssue({ code: "custom", path, message }); + +const assertUnique = ( + values: readonly string[], + path: PropertyKey[], + label: string, + context: z.RefinementCtx, +): void => { + if (new Set(values).size !== values.length) + addIssue(context, path, `${label} must be unique`); +}; + +const originFor = (destination: BrowserScenarioUrl): string => + new URL(destination.url).origin; + +const assertAllowedDestination = ( + destination: BrowserScenarioUrl, + allowedOrigins: ReadonlySet, + path: PropertyKey[], + context: z.RefinementCtx, +): void => { + if (!allowedOrigins.has(originFor(destination))) + addIssue( + context, + path, + `URL origin ${originFor(destination)} is not declared in allowed_origins`, + ); +}; + +const secretReferencesInValue = ( + value: BrowserScenarioValue | undefined, +): readonly string[] => (value?.source === "secret" ? [value.secret_id] : []); + +const destinationSecretReferences = ( + destination: BrowserScenarioUrl, +): readonly string[] => + destination.query.flatMap(({ value }) => secretReferencesInValue(value)); + +const actionSecretReferences = ( + action: BrowserScenarioAction, +): readonly string[] => { + if (action.action === "fill" || action.action === "select_option") + return secretReferencesInValue(action.value); + if (action.action === "goto") + return destinationSecretReferences(action.destination); + return []; +}; + +const validateStorage = ( + scenario: ScenarioShape, + allowedOrigins: ReadonlySet, + context: z.RefinementCtx, +): readonly string[] => { + const references: string[] = []; + scenario.storage.cookies.forEach((cookie, index) => { + assertAllowedDestination( + cookie.destination, + allowedOrigins, + ["storage", "cookies", index, "destination"], + context, + ); + references.push(...secretReferencesInValue(cookie.value)); + references.push(...destinationSecretReferences(cookie.destination)); + }); + for (const collection of ["local_storage", "session_storage"] as const) + scenario.storage[collection].forEach((storage, index) => { + if (!allowedOrigins.has(storage.origin)) + addIssue( + context, + ["storage", collection, index, "origin"], + `Storage origin ${storage.origin} is not declared in allowed_origins`, + ); + for (const entry of storage.entries) + references.push(...secretReferencesInValue(entry.value)); + }); + return references; +}; + +const SENSITIVE_HEADERS = new Set([ + "authorization", + "cookie", + "proxy-authorization", + "set-cookie", +]); +const FORBIDDEN_REPLAY_HEADERS = new Set([ + "content-length", + "location", + "transfer-encoding", +]); + +const validateRequestReplay = ( + scenario: ScenarioShape, + allowedOrigins: ReadonlySet, + context: z.RefinementCtx, +): readonly string[] => { + if (scenario.request_replay.mode === "disabled") return []; + assertUnique( + scenario.request_replay.routes.map(({ route_id: id }) => id), + ["request_replay", "routes"], + "Replay route IDs", + context, + ); + assertUnique( + scenario.request_replay.routes.map( + ({ method, request }) => + `${method} ${request.url}?${request.query + .map(({ name, value }) => + value.source === "secret" + ? `${name}=\${secret:${value.secret_id}}` + : `${name}=${value.value}`, + ) + .join("&")}`, + ), + ["request_replay", "routes"], + "Replay request matchers", + context, + ); + const references: string[] = []; + scenario.request_replay.routes.forEach((route, routeIndex) => { + assertAllowedDestination( + route.request, + allowedOrigins, + ["request_replay", "routes", routeIndex, "request"], + context, + ); + references.push(...destinationSecretReferences(route.request)); + if (route.response.kind === "redirect") { + assertAllowedDestination( + route.response.destination, + allowedOrigins, + ["request_replay", "routes", routeIndex, "response", "destination"], + context, + ); + references.push( + ...destinationSecretReferences(route.response.destination), + ); + return; + } + assertUnique( + route.response.headers.map(({ name }) => name.toLowerCase()), + ["request_replay", "routes", routeIndex, "response", "headers"], + "Replay response header names", + context, + ); + route.response.headers.forEach((header, headerIndex) => { + if (FORBIDDEN_REPLAY_HEADERS.has(header.name)) + addIssue( + context, + [ + "request_replay", + "routes", + routeIndex, + "response", + "headers", + headerIndex, + ], + `Replay header ${header.name} must be modeled by the provider`, + ); + if ( + SENSITIVE_HEADERS.has(header.name) && + header.value.source === "literal" + ) + addIssue( + context, + [ + "request_replay", + "routes", + routeIndex, + "response", + "headers", + headerIndex, + "value", + ], + `Credential header ${header.name} requires a declared secret`, + ); + references.push(...secretReferencesInValue(header.value)); + }); + references.push(...secretReferencesInValue(route.response.body)); + }); + return references; +}; + +const validateSecretReferences = ( + scenario: ScenarioShape, + references: readonly string[], + context: z.RefinementCtx, +): void => { + const declared = new Set(scenario.secrets.map(({ secret_id: id }) => id)); + for (const reference of references) + if (!declared.has(reference)) + addIssue( + context, + ["secrets"], + `Secret reference ${reference} is not declared`, + ); + const used = new Set(references); + scenario.secrets.forEach(({ secret_id: id }, index) => { + if (!used.has(id)) + addIssue( + context, + ["secrets", index, "secret_id"], + `Secret declaration ${id} is unused`, + ); + }); +}; + +const validateRedactionCoverage = ( + scenario: ScenarioShape, + context: z.RefinementCtx, +): void => { + const redactedQueries = new Set(scenario.redaction.query_parameter_names); + const destinations: BrowserScenarioUrl[] = [scenario.start_url]; + for (const action of scenario.actions) + if (action.action === "goto") destinations.push(action.destination); + for (const cookie of scenario.storage.cookies) + destinations.push(cookie.destination); + if (scenario.request_replay.mode === "exact") + for (const route of scenario.request_replay.routes) { + destinations.push(route.request); + if (route.response.kind === "redirect") + destinations.push(route.response.destination); + } + for (const destination of destinations) + for (const query of destination.query) + if ( + query.value.source === "secret" && + !redactedQueries.has(query.name.toLowerCase()) + ) + addIssue( + context, + ["redaction", "query_parameter_names"], + `Secret query parameter ${query.name} must be declared for redaction`, + ); +}; + +const validateBrowserScenario = ( + scenario: ScenarioShape, + context: z.RefinementCtx, +): void => { + const allowedOrigins = new Set(scenario.allowed_origins); + assertAllowedDestination( + scenario.start_url, + allowedOrigins, + ["start_url"], + context, + ); + assertUnique( + scenario.actions.map(({ step_id: id }) => id), + ["actions"], + "Action step IDs", + context, + ); + assertUnique( + scenario.secrets.map(({ secret_id: id }) => id), + ["secrets"], + "Secret IDs", + context, + ); + assertUnique( + scenario.secrets.map(({ environment_variable: name }) => name), + ["secrets"], + "Secret environment variables", + context, + ); + + const references = scenario.actions.flatMap(actionSecretReferences); + for (const [index, action] of scenario.actions.entries()) + if (action.action === "goto") + assertAllowedDestination( + action.destination, + allowedOrigins, + ["actions", index, "destination"], + context, + ); + references.push(...destinationSecretReferences(scenario.start_url)); + references.push(...validateStorage(scenario, allowedOrigins, context)); + references.push(...validateRequestReplay(scenario, allowedOrigins, context)); + validateSecretReferences(scenario, references, context); + validateRedactionCoverage(scenario, context); +}; + +/** Strict provider-neutral contract for one bounded browser capture scenario. */ +export const browserScenarioSchema = scenarioShapeSchema.superRefine( + validateBrowserScenario, +); +export type BrowserScenario = z.infer; diff --git a/src/domain/browserScenarioCapture.ts b/src/domain/browserScenarioCapture.ts new file mode 100644 index 000000000..ea70eb2fc --- /dev/null +++ b/src/domain/browserScenarioCapture.ts @@ -0,0 +1,85 @@ +import { z } from "zod"; + +import { + browserScenarioCompletenessSchema, + browserScenarioEventSchema, + browserScenarioStepSchema, +} from "./browserScenarioCaptureValues.js"; + +export { + browserScenarioCompletenessSchema, + browserScenarioEventSchema, + browserScenarioStepSchema, + browserStepArtifactsSchema, + captureStateSchema, + type BrowserScenarioCompleteness, + type BrowserScenarioEvent, + type BrowserScenarioStep, + type BrowserStepArtifacts, +} from "./browserScenarioCaptureValues.js"; + +/** Step-indexed, explicitly bounded browser scenario observation. */ +export const browserScenarioCaptureSchema = z + .strictObject({ + schema_version: z.literal(1), + browser: z.strictObject({ + mode: z.enum(["launch", "connect"]), + process_ownership: z.enum(["provider-owned", "external"]), + cleanup: z.enum(["terminated-owned-process", "disconnected-external"]), + product: z.string().min(1).max(1_024), + version: z.string().min(1).max(256), + }), + scenario: z.strictObject({ + start_origin: z.string().min(1).max(2_048), + allowed_origins: z.array(z.string().min(1).max(2_048)).min(1).max(32), + action_count: z.number().int().min(1).max(128), + secret_references: z.array(z.string().min(1).max(64)).max(64), + }), + duration_ms: z.number().int().min(0), + steps: z.array(browserScenarioStepSchema).min(1).max(129), + events: z.strictObject({ + retained: z.number().int().min(0), + dropped: z.number().int().min(0), + items: z.array(browserScenarioEventSchema).max(100_000), + }), + completeness: browserScenarioCompletenessSchema, + limitations: z.array(z.string().min(1).max(2_048)).max(64), + }) + .superRefine((capture, context) => { + if (capture.events.retained !== capture.events.items.length) + context.addIssue({ + code: "custom", + path: ["events", "retained"], + message: "Retained event count must equal items length", + }); + if (capture.steps.length !== capture.scenario.action_count + 1) + context.addIssue({ + code: "custom", + path: ["steps"], + message: "Capture must include initial state and one entry per action", + }); + const expectedIndices = capture.steps.map((_, index) => index); + if ( + capture.steps.some( + ({ step_index: index }, offset) => index !== expectedIndices[offset], + ) + ) + context.addIssue({ + code: "custom", + path: ["steps"], + message: "Step indices must be contiguous from zero", + }); + if ( + capture.events.items.some( + ({ step_index: index }) => index >= capture.steps.length, + ) + ) + context.addIssue({ + code: "custom", + path: ["events", "items"], + message: "Event references an unknown step index", + }); + }); +export type BrowserScenarioCapture = z.infer< + typeof browserScenarioCaptureSchema +>; diff --git a/src/domain/browserScenarioCaptureValues.ts b/src/domain/browserScenarioCaptureValues.ts new file mode 100644 index 000000000..c44cf2204 --- /dev/null +++ b/src/domain/browserScenarioCaptureValues.ts @@ -0,0 +1,231 @@ +import { createHash } from "node:crypto"; + +import { z } from "zod"; + +import { sanitizedBrowserUrlSchema } from "./browserObservation.js"; +import { webScreenshotArtifactSchema } from "./webScreenshot.js"; + +const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u); +const boundedTextSchema = z.string().max(1_048_576); + +const textArtifactSchema = z + .strictObject({ + sha256: digestSchema, + bytes: z + .number() + .int() + .min(0) + .max(16 * 1_024 * 1_024), + text: boundedTextSchema, + }) + .superRefine((artifact, context) => { + const bytes = Buffer.from(artifact.text); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + if (bytes.byteLength !== artifact.bytes || sha256 !== artifact.sha256) + context.addIssue({ + code: "custom", + message: "Text artifact digest or size mismatch", + }); + }); + +export const captureStateSchema = (value: Schema) => + z.discriminatedUnion("state", [ + z.strictObject({ state: z.literal("captured"), value }), + z.strictObject({ + state: z.literal("not_requested"), + }), + z.strictObject({ + state: z.literal("missing"), + reason: z.string().min(1).max(1_024), + }), + z.strictObject({ + state: z.literal("truncated"), + observed: z.number().int().min(0), + retained: z.number().int().min(0), + reason: z.string().min(1).max(1_024), + }), + ]); + +const historySchema = z.strictObject({ + length: z.number().int().min(0), + current_url: sanitizedBrowserUrlSchema, + navigation_entries: z + .array( + z.strictObject({ + type: z.enum([ + "navigate", + "reload", + "back_forward", + "prerender", + "unknown", + ]), + name: sanitizedBrowserUrlSchema, + }), + ) + .max(256), +}); + +const storageValueSchema = z.strictObject({ + name: z.string().max(1_024), + value_state: z.enum(["hashed", "redacted-secret"]), + value_sha256: digestSchema.nullable(), +}); + +const storageSnapshotSchema = z.strictObject({ + cookies: z + .array( + z.strictObject({ + name: z.string().max(1_024), + domain: z.string().max(2_048), + path: z.string().max(4_096), + secure: z.boolean(), + http_only: z.boolean(), + same_site: z.enum(["Strict", "Lax", "None"]), + value_state: z.enum(["hashed", "redacted-secret"]), + value_sha256: digestSchema.nullable(), + }), + ) + .max(512), + local_storage: z.array(storageValueSchema).max(512), + session_storage: z.array(storageValueSchema).max(512), +}); + +export const browserStepArtifactsSchema = z.strictObject({ + screenshot: captureStateSchema(webScreenshotArtifactSchema), + dom: captureStateSchema(textArtifactSchema), + accessibility: captureStateSchema(textArtifactSchema), + url: captureStateSchema(sanitizedBrowserUrlSchema), + history: captureStateSchema(historySchema), + storage: captureStateSchema(storageSnapshotSchema), +}); +export type BrowserStepArtifacts = z.infer; + +const eventBase = { + sequence: z.number().int().min(1), + step_index: z.number().int().min(0), +}; + +const eventUrl = sanitizedBrowserUrlSchema.nullable(); + +export const browserScenarioEventSchema = z.discriminatedUnion("kind", [ + z.strictObject({ + ...eventBase, + kind: z.literal("console"), + level: z.string().min(1).max(64), + text: z.string().max(65_536), + url: eventUrl, + }), + z.strictObject({ + ...eventBase, + kind: z.literal("page-error"), + message: z.string().max(65_536), + stack: z.string().max(262_144).nullable(), + }), + z.strictObject({ + ...eventBase, + kind: z.enum(["request", "response", "request-failed"]), + method: z.string().min(1).max(32), + url: sanitizedBrowserUrlSchema, + resource_type: z.string().min(1).max(64), + status: z.number().int().min(100).max(599).nullable(), + header_names: z.array(z.string().max(256)).max(256), + failure: z.string().max(1_024).nullable(), + }), + z.strictObject({ + ...eventBase, + kind: z.enum(["websocket-opened", "websocket-closed"]), + url: sanitizedBrowserUrlSchema, + }), + z.strictObject({ + ...eventBase, + kind: z.enum(["websocket-frame-sent", "websocket-frame-received"]), + url: sanitizedBrowserUrlSchema, + payload_type: z.enum(["text", "binary"]), + payload_bytes: z.number().int().min(0), + payload_text: z.string().max(65_536).nullable(), + truncated: z.boolean(), + }), + z.strictObject({ + ...eventBase, + kind: z.enum([ + "frame-attached", + "frame-detached", + "frame-navigated", + "worker-created", + "worker-closed", + "popup-opened", + "popup-closed", + ]), + url: eventUrl, + name: z.string().max(1_024).nullable(), + }), + z.strictObject({ + ...eventBase, + kind: z.literal("download-cancelled"), + suggested_filename: z.string().max(1_024), + url: sanitizedBrowserUrlSchema, + }), +]); +export type BrowserScenarioEvent = z.infer; + +const completenessSectionSchema = z.enum([ + "action", + "screenshot", + "dom", + "accessibility", + "url", + "history", + "storage", + "events", + "frames", + "workers", + "popups", + "websockets", +]); + +export const browserScenarioCompletenessSchema = z + .strictObject({ + status: z.enum(["complete", "incomplete", "truncated"]), + equality_eligible: z.boolean(), + missing_sections: z.array(completenessSectionSchema), + truncated_sections: z.array(completenessSectionSchema), + }) + .superRefine((value, context) => { + const expectedStatus = + value.truncated_sections.length > 0 + ? "truncated" + : value.missing_sections.length > 0 + ? "incomplete" + : "complete"; + if (value.status !== expectedStatus) + context.addIssue({ + code: "custom", + path: ["status"], + message: "Completeness status does not match section accounting", + }); + if (value.equality_eligible !== (expectedStatus === "complete")) + context.addIssue({ + code: "custom", + path: ["equality_eligible"], + message: "Missing or truncated evidence is never equality-eligible", + }); + }); +export type BrowserScenarioCompleteness = z.infer< + typeof browserScenarioCompletenessSchema +>; + +export const browserScenarioStepSchema = z.strictObject({ + step_index: z.number().int().min(0), + step_id: z.string().min(1).max(64), + action: z.string().min(1).max(64), + status: z.enum(["completed", "failed", "cancelled"]), + elapsed_ms: z.number().int().min(0), + before_url: sanitizedBrowserUrlSchema, + after_url: sanitizedBrowserUrlSchema, + error: z.string().max(4_096).nullable(), + event_sequence_start: z.number().int().min(1), + event_sequence_end: z.number().int().min(0), + artifacts: browserStepArtifactsSchema, + completeness: browserScenarioCompletenessSchema, +}); +export type BrowserScenarioStep = z.infer; diff --git a/src/domain/browserScenarioDiff.ts b/src/domain/browserScenarioDiff.ts new file mode 100644 index 000000000..29c8adbe6 --- /dev/null +++ b/src/domain/browserScenarioDiff.ts @@ -0,0 +1,513 @@ +import { + type BrowserScenarioCapture, + type BrowserScenarioStep, + type BrowserStepArtifacts, +} from "./browserScenarioCapture.js"; +import { + browserScenarioDiffSchema, + type BrowserScenarioAlignmentFailure, + type BrowserScenarioArtifactDiff, + type BrowserScenarioArtifactKind, + type BrowserScenarioDiff, + type BrowserScenarioEvidenceState, + type BrowserScenarioNormalizationRule, + type CompareBrowserScenariosInput, +} from "./browserScenarioDiffValues.js"; +import { + commitBrowserScenarioNormalization, + digestCanonicalJson, + digestNormalizedScenarioValue, +} from "./browserScenarioNormalization.js"; + +export { + browserScenarioDiffSchema, + compareBrowserScenariosInputSchema, + type BrowserScenarioDiff, + type CompareBrowserScenariosInput, +} from "./browserScenarioDiffValues.js"; + +type CaptureState = + | { readonly state: "captured"; readonly value: T } + | { readonly state: "not_requested" } + | { readonly state: "missing"; readonly reason: string } + | { + readonly state: "truncated"; + readonly observed: number; + readonly retained: number; + readonly reason: string; + }; + +interface ComparableArtifact { + readonly artifact: BrowserScenarioArtifactKind; + readonly status: "changed" | "unchanged" | "unknown" | "not_compared"; + readonly diff: BrowserScenarioArtifactDiff | null; +} + +interface ScenarioAlignment { + readonly before: ReadonlyMap; + readonly after: ReadonlyMap; + readonly orderedStepIds: readonly string[]; + readonly beforeOnly: readonly string[]; + readonly afterOnly: readonly string[]; + readonly failures: readonly BrowserScenarioAlignmentFailure[]; +} + +interface AlignedStepComparison { + readonly steps: BrowserScenarioDiff["steps"]; + readonly failures: readonly BrowserScenarioAlignmentFailure[]; + readonly totalArtifactDiffs: number; +} + +/** Compare scenario steps by stable step ID and apply only committed literal rules. */ +export const compareBrowserScenarios = ( + input: CompareBrowserScenariosInput, +): BrowserScenarioDiff => { + const normalization = commitBrowserScenarioNormalization(input.normalization); + const alignment = alignScenarioSteps( + input.before_scenario, + input.after_scenario, + ); + const compared = compareAlignedSteps({ + beforeCapture: input.before_scenario, + afterCapture: input.after_scenario, + alignment, + rules: normalization.rules, + maxChanges: input.max_changes, + }); + const failures = [...alignment.failures, ...compared.failures]; + const alignmentStatus = + failures.length === 0 + ? "aligned" + : compared.steps.length === 0 + ? "failed" + : "partial"; + const stepStatuses = compared.steps.map(({ status }) => status); + const equalityEligible = + input.before_scenario.completeness.equality_eligible && + input.after_scenario.completeness.equality_eligible && + alignmentStatus === "aligned"; + const overallStatus = stepStatuses.includes("changed") + ? "changed" + : stepStatuses.includes("unknown") || !equalityEligible + ? "unknown" + : "unchanged"; + + return browserScenarioDiffSchema.parse({ + schema_version: 1, + comparison_kind: "browser_scenario", + overall_status: overallStatus, + normalization, + alignment: { + status: alignmentStatus, + aligned_steps: compared.steps.length, + before_only: alignment.beforeOnly, + after_only: alignment.afterOnly, + failures, + }, + steps: compared.steps, + artifact_diffs: { + total: compared.totalArtifactDiffs, + retained: + compared.totalArtifactDiffs - + Math.max(0, compared.totalArtifactDiffs - input.max_changes), + omitted: Math.max(0, compared.totalArtifactDiffs - input.max_changes), + }, + limitations: [ + "Steps align only by exact, unique step_id; missing, duplicate, or action-mismatched steps are reported and prevent an unchanged claim.", + "Only declared literal normalization rules are applied, in canonical rule_id order, to already-redacted durable capture fields.", + "Elapsed time and event sequence/index values are excluded as volatile; screenshot bytes are compared by content digest.", + "A changed status proves an observed normalized difference. Missing or truncated evidence, incompatible capture context, or alignment failure prevents an unchanged claim.", + ...(compared.totalArtifactDiffs > input.max_changes + ? [ + `${String(compared.totalArtifactDiffs - input.max_changes)} artifact difference record(s) were omitted by max_changes.`, + ] + : []), + ], + }); +}; + +const alignScenarioSteps = ( + beforeCapture: BrowserScenarioCapture, + afterCapture: BrowserScenarioCapture, +): ScenarioAlignment => { + const beforeDuplicates = duplicateStepIds(beforeCapture.steps); + const afterDuplicates = duplicateStepIds(afterCapture.steps); + const before = uniqueSteps(beforeCapture.steps, beforeDuplicates); + const after = uniqueSteps(afterCapture.steps, afterDuplicates); + const beforeOnly = [...before.keys()] + .filter((stepId) => !after.has(stepId)) + .sort(); + const afterOnly = [...after.keys()] + .filter((stepId) => !before.has(stepId)) + .sort(); + const failures: BrowserScenarioAlignmentFailure[] = [ + ...duplicateFailures("before", beforeDuplicates), + ...duplicateFailures("after", afterDuplicates), + ...missingFailures("before", afterOnly), + ...missingFailures("after", beforeOnly), + ]; + if (!captureContextsMatch(beforeCapture, afterCapture)) + failures.push({ + code: "capture_context_mismatch", + step_id: null, + reason: + "Browser product/version or scenario origin scope differs between captures.", + }); + return { + before, + after, + beforeOnly, + afterOnly, + failures, + orderedStepIds: beforeCapture.steps + .map(({ step_id }) => step_id) + .filter( + (stepId, index, values) => + values.indexOf(stepId) === index && + before.has(stepId) && + after.has(stepId), + ), + }; +}; + +interface CompareAlignedStepsInput { + readonly beforeCapture: BrowserScenarioCapture; + readonly afterCapture: BrowserScenarioCapture; + readonly alignment: ScenarioAlignment; + readonly rules: readonly BrowserScenarioNormalizationRule[]; + readonly maxChanges: number; +} + +const compareAlignedSteps = ( + input: CompareAlignedStepsInput, +): AlignedStepComparison => { + const remaining = { value: input.maxChanges }; + let totalArtifactDiffs = 0; + const steps: BrowserScenarioDiff["steps"] = []; + const failures: BrowserScenarioAlignmentFailure[] = []; + for (const stepId of input.alignment.orderedStepIds) { + const beforeStep = input.alignment.before.get(stepId); + const afterStep = input.alignment.after.get(stepId); + if (beforeStep === undefined || afterStep === undefined) continue; + if (beforeStep.action !== afterStep.action) + failures.push({ + code: "action_mismatch", + step_id: stepId, + reason: `Aligned step action changed from ${beforeStep.action} to ${afterStep.action}.`, + }); + const artifacts = compareStepArtifacts({ + beforeCapture: input.beforeCapture, + beforeStep, + afterCapture: input.afterCapture, + afterStep, + rules: input.rules, + }); + const reportable = artifacts.flatMap(({ diff }) => + diff === null ? [] : [diff], + ); + totalArtifactDiffs += reportable.length; + const retained = reportable.slice(0, remaining.value); + remaining.value -= retained.length; + const statuses = artifacts.map(({ status }) => status); + steps.push({ + step_id: stepId, + before_step_index: beforeStep.step_index, + after_step_index: afterStep.step_index, + before_action: beforeStep.action, + after_action: afterStep.action, + status: statuses.includes("changed") + ? "changed" + : statuses.includes("unknown") + ? "unknown" + : "unchanged", + artifact_diffs: retained, + omitted_artifact_diffs: reportable.length - retained.length, + }); + } + return { steps, failures, totalArtifactDiffs }; +}; + +const duplicateStepIds = ( + steps: readonly BrowserScenarioStep[], +): ReadonlySet => { + const seen = new Set(); + const duplicates = new Set(); + for (const { step_id: stepId } of steps) { + if (seen.has(stepId)) duplicates.add(stepId); + seen.add(stepId); + } + return duplicates; +}; + +const uniqueSteps = ( + steps: readonly BrowserScenarioStep[], + duplicates: ReadonlySet, +): ReadonlyMap => + new Map( + steps.flatMap((step) => + duplicates.has(step.step_id) ? [] : [[step.step_id, step] as const], + ), + ); + +const duplicateFailures = ( + side: "before" | "after", + duplicates: ReadonlySet, +): BrowserScenarioAlignmentFailure[] => + [...duplicates].sort().map((stepId) => ({ + code: + side === "before" + ? "duplicate_before_step_id" + : "duplicate_after_step_id", + step_id: stepId, + reason: `${side} capture contains duplicate step_id ${stepId}.`, + })); + +const missingFailures = ( + side: "before" | "after", + stepIds: readonly string[], +): BrowserScenarioAlignmentFailure[] => + stepIds.map((stepId) => ({ + code: side === "before" ? "missing_before_step" : "missing_after_step", + step_id: stepId, + reason: `Step ${stepId} is missing from the ${side} capture.`, + })); + +const captureContextsMatch = ( + before: BrowserScenarioCapture, + after: BrowserScenarioCapture, +): boolean => + before.browser.product === after.browser.product && + before.browser.version === after.browser.version && + before.scenario.start_origin === after.scenario.start_origin && + digestCanonicalJson([...before.scenario.allowed_origins].sort()) === + digestCanonicalJson([...after.scenario.allowed_origins].sort()); + +interface CompareStepArtifactsInput { + readonly beforeCapture: BrowserScenarioCapture; + readonly beforeStep: BrowserScenarioStep; + readonly afterCapture: BrowserScenarioCapture; + readonly afterStep: BrowserScenarioStep; + readonly rules: readonly BrowserScenarioNormalizationRule[]; +} + +const compareStepArtifacts = ( + input: CompareStepArtifactsInput, +): ComparableArtifact[] => { + const common = { + beforeStep: input.beforeStep, + afterStep: input.afterStep, + rules: input.rules, + }; + return [ + compareCapturedValues({ + ...common, + artifact: "action_state", + before: captured(actionState(input.beforeStep)), + after: captured(actionState(input.afterStep)), + }), + compareCapturedValues({ + ...common, + artifact: "screenshot", + before: input.beforeStep.artifacts.screenshot, + after: input.afterStep.artifacts.screenshot, + project: screenshotProjection, + }), + compareCapturedValues({ + ...common, + artifact: "dom", + before: input.beforeStep.artifacts.dom, + after: input.afterStep.artifacts.dom, + project: ({ text }) => text, + }), + compareCapturedValues({ + ...common, + artifact: "accessibility", + before: input.beforeStep.artifacts.accessibility, + after: input.afterStep.artifacts.accessibility, + project: ({ text }) => text, + }), + compareCapturedValues({ + ...common, + artifact: "url", + before: input.beforeStep.artifacts.url, + after: input.afterStep.artifacts.url, + }), + compareCapturedValues({ + ...common, + artifact: "history", + before: input.beforeStep.artifacts.history, + after: input.afterStep.artifacts.history, + }), + compareCapturedValues({ + ...common, + artifact: "storage", + before: input.beforeStep.artifacts.storage, + after: input.afterStep.artifacts.storage, + }), + compareCapturedValues({ + ...common, + artifact: "events", + before: captured( + eventsFor(input.beforeCapture, input.beforeStep.step_index), + ), + after: captured( + eventsFor(input.afterCapture, input.afterStep.step_index), + ), + beforeState: eventEvidenceState(input.beforeCapture, input.beforeStep), + afterState: eventEvidenceState(input.afterCapture, input.afterStep), + }), + ]; +}; + +interface CompareCapturedValuesInput { + readonly artifact: BrowserScenarioArtifactKind; + readonly before: CaptureState; + readonly after: CaptureState; + readonly beforeStep: BrowserScenarioStep; + readonly afterStep: BrowserScenarioStep; + readonly rules: readonly BrowserScenarioNormalizationRule[]; + readonly project?: (value: Value) => unknown; + readonly beforeState?: BrowserScenarioEvidenceState; + readonly afterState?: BrowserScenarioEvidenceState; +} + +const compareCapturedValues = ( + input: CompareCapturedValuesInput, +): ComparableArtifact => { + const { + artifact, + before, + after, + beforeStep, + afterStep, + rules, + project = (value) => value, + } = input; + const beforeState = + input.beforeState ?? artifactEvidenceState(artifact, before, beforeStep); + const afterState = + input.afterState ?? artifactEvidenceState(artifact, after, afterStep); + if (beforeState === "not_requested" && afterState === "not_requested") + return { artifact, status: "not_compared", diff: null }; + const beforeSha256 = + beforeState === "captured" && before.state === "captured" + ? digestNormalizedScenarioValue(project(before.value), artifact, rules) + : null; + const afterSha256 = + afterState === "captured" && after.state === "captured" + ? digestNormalizedScenarioValue(project(after.value), artifact, rules) + : null; + if ( + beforeState !== "captured" || + afterState !== "captured" || + before.state !== "captured" || + after.state !== "captured" + ) + return { + artifact, + status: "unknown", + diff: { + artifact, + status: "unknown", + before_state: beforeState, + after_state: afterState, + before_sha256: beforeSha256, + after_sha256: afterSha256, + reason: `Artifact coverage is not comparable (${beforeState} before, ${afterState} after).`, + }, + }; + if (beforeSha256 === null || afterSha256 === null) + throw new TypeError("Captured artifact digest refinement failed"); + if (beforeSha256 === afterSha256) + return { artifact, status: "unchanged", diff: null }; + return { + artifact, + status: "changed", + diff: { + artifact, + status: "changed", + before_state: beforeState, + after_state: afterState, + before_sha256: beforeSha256, + after_sha256: afterSha256, + reason: null, + }, + }; +}; + +const artifactEvidenceState = ( + artifact: BrowserScenarioArtifactKind, + state: CaptureState, + step: BrowserScenarioStep, +): BrowserScenarioEvidenceState => { + if (state.state !== "captured") return state.state; + const section = artifact === "action_state" ? "action" : artifact; + if (step.completeness.truncated_sections.includes(section)) + return "truncated"; + if (step.completeness.missing_sections.includes(section)) return "missing"; + return "captured"; +}; + +const eventEvidenceState = ( + capture: BrowserScenarioCapture, + step: BrowserScenarioStep, +): BrowserScenarioEvidenceState => { + const sections = [ + "events", + "frames", + "workers", + "popups", + "websockets", + ] as const; + if ( + capture.events.dropped > 0 || + sections.some( + (section) => + capture.completeness.truncated_sections.includes(section) || + step.completeness.truncated_sections.includes(section), + ) + ) + return "truncated"; + if ( + sections.some( + (section) => + capture.completeness.missing_sections.includes(section) || + step.completeness.missing_sections.includes(section), + ) + ) + return "missing"; + return "captured"; +}; + +const actionState = (step: BrowserScenarioStep) => ({ + action: step.action, + status: step.status, + before_url: step.before_url, + after_url: step.after_url, + error: step.error, +}); + +const eventsFor = ( + capture: BrowserScenarioCapture, + stepIndex: number, +): readonly unknown[] => + capture.events.items.flatMap( + ({ sequence: _sequence, step_index: eventStepIndex, ...event }) => + eventStepIndex === stepIndex ? [event] : [], + ); + +const captured = (value: Value): CaptureState => ({ + state: "captured", + value, +}); + +const screenshotProjection = ( + screenshot: Extract< + BrowserStepArtifacts["screenshot"], + { state: "captured" } + >["value"], +) => ({ + sha256: screenshot.sha256, + bytes: screenshot.bytes, + media_type: screenshot.media_type, +}); diff --git a/src/domain/browserScenarioDiffValues.ts b/src/domain/browserScenarioDiffValues.ts new file mode 100644 index 000000000..dbd318ea0 --- /dev/null +++ b/src/domain/browserScenarioDiffValues.ts @@ -0,0 +1,201 @@ +import { z } from "zod"; + +import { browserScenarioCaptureSchema } from "./browserScenarioCapture.js"; + +/** Scenario artifact families compared after step alignment. */ +export const browserScenarioArtifactKindSchema = z.enum([ + "action_state", + "screenshot", + "dom", + "accessibility", + "url", + "history", + "storage", + "events", +]); +/** Scenario artifact family. */ +export type BrowserScenarioArtifactKind = z.infer< + typeof browserScenarioArtifactKindSchema +>; + +const normalizableArtifactKindSchema = z.enum([ + "action_state", + "dom", + "accessibility", + "url", + "history", + "storage", + "events", +]); + +const normalizationRuleSchema = z.strictObject({ + rule_id: z + .string() + .trim() + .min(1) + .max(64) + .describe("Stable rule identity; rules execute in rule_id order."), + artifacts: z + .array(normalizableArtifactKindSchema) + .min(1) + .max(7) + .describe("Artifact families whose durable string fields use this rule."), + match: z + .string() + .min(1) + .max(4_096) + .describe( + "Exact public literal to replace; regular expressions are not used.", + ), + replacement: z + .string() + .max(4_096) + .describe("Recorded replacement text used in normalized comparison."), +}); + +const normalizationInputSchema = z + .strictObject({ + rules: z.array(normalizationRuleSchema).max(128).default([]), + }) + .superRefine((normalization, context) => { + const ruleIds = new Set(); + for (const [index, rule] of normalization.rules.entries()) { + if (ruleIds.has(rule.rule_id)) + context.addIssue({ + code: "custom", + path: ["rules", index, "rule_id"], + message: "Normalization rule IDs must be unique", + }); + ruleIds.add(rule.rule_id); + if (new Set(rule.artifacts).size !== rule.artifacts.length) + context.addIssue({ + code: "custom", + path: ["rules", index, "artifacts"], + message: "Normalization rule artifact kinds must be unique", + }); + } + }); + +/** Input for step-aligned comparison of two browser scenario captures. */ +export const compareBrowserScenariosInputSchema = z.strictObject({ + before_scenario: browserScenarioCaptureSchema.describe( + "Earlier complete capture_browser_scenario result.", + ), + after_scenario: browserScenarioCaptureSchema.describe( + "Later complete capture_browser_scenario result.", + ), + normalization: normalizationInputSchema + .default({ rules: [] }) + .describe( + "Bounded exact-literal rules committed in the comparison result.", + ), + max_changes: z + .number() + .int() + .min(1) + .max(20_000) + .default(2_000) + .describe("Maximum changed or unknown artifact records to retain."), +}); +/** Parsed browser scenario comparison input. */ +export type CompareBrowserScenariosInput = z.infer< + typeof compareBrowserScenariosInputSchema +>; + +const evidenceStateSchema = z.enum([ + "captured", + "not_requested", + "missing", + "truncated", +]); + +const artifactDiffSchema = z.strictObject({ + artifact: browserScenarioArtifactKindSchema, + status: z.enum(["changed", "unknown"]), + before_state: evidenceStateSchema, + after_state: evidenceStateSchema, + before_sha256: z + .string() + .regex(/^[a-f0-9]{64}$/u) + .nullable(), + after_sha256: z + .string() + .regex(/^[a-f0-9]{64}$/u) + .nullable(), + reason: z.string().min(1).max(2_048).nullable(), +}); +const alignmentFailureSchema = z.strictObject({ + code: z.enum([ + "duplicate_before_step_id", + "duplicate_after_step_id", + "missing_before_step", + "missing_after_step", + "action_mismatch", + "capture_context_mismatch", + ]), + step_id: z.string().min(1).max(64).nullable(), + reason: z.string().min(1).max(2_048), +}); +const builtInNormalizationSchema = z.enum([ + "ignore_step_elapsed_ms", + "ignore_event_sequence", + "ignore_event_step_index", + "compare_screenshot_content_digest", + "compare_normalized_text_content", +]); + +/** Step alignment, recorded normalization, and bounded artifact differences. */ +export const browserScenarioDiffSchema = z.strictObject({ + schema_version: z.literal(1), + comparison_kind: z.literal("browser_scenario"), + overall_status: z.enum(["changed", "unchanged", "unknown"]), + normalization: z.strictObject({ + built_in_rules: z.array(builtInNormalizationSchema).length(5), + rules: z.array(normalizationRuleSchema).max(128), + sha256: z.string().regex(/^[a-f0-9]{64}$/u), + }), + alignment: z.strictObject({ + status: z.enum(["aligned", "partial", "failed"]), + aligned_steps: z.number().int().min(0).max(129), + before_only: z.array(z.string().min(1).max(64)).max(129), + after_only: z.array(z.string().min(1).max(64)).max(129), + failures: z.array(alignmentFailureSchema).max(520), + }), + steps: z + .array( + z.strictObject({ + step_id: z.string().min(1).max(64), + before_step_index: z.number().int().min(0).max(128), + after_step_index: z.number().int().min(0).max(128), + before_action: z.string().min(1).max(64), + after_action: z.string().min(1).max(64), + status: z.enum(["changed", "unchanged", "unknown"]), + artifact_diffs: z.array(artifactDiffSchema).max(8), + omitted_artifact_diffs: z.number().int().min(0).max(8), + }), + ) + .max(129), + artifact_diffs: z.strictObject({ + total: z.number().int().min(0).max(1_032), + retained: z.number().int().min(0).max(1_032), + omitted: z.number().int().min(0).max(1_032), + }), + limitations: z.array(z.string().min(1).max(2_048)).max(64), +}); +/** Browser scenario comparison result. */ +export type BrowserScenarioDiff = z.infer; +/** Reportable changed or unknown scenario artifact. */ +export type BrowserScenarioArtifactDiff = + BrowserScenarioDiff["steps"][number]["artifact_diffs"][number]; +/** One explicit reason scenario step alignment was not exact. */ +export type BrowserScenarioAlignmentFailure = + BrowserScenarioDiff["alignment"]["failures"][number]; +/** Comparable capture state. */ +export type BrowserScenarioEvidenceState = + BrowserScenarioArtifactDiff["before_state"]; +/** Scenario normalization policy after boundary parsing. */ +export type BrowserScenarioNormalization = + CompareBrowserScenariosInput["normalization"]; +/** One exact literal scenario normalization rule. */ +export type BrowserScenarioNormalizationRule = + BrowserScenarioNormalization["rules"][number]; diff --git a/src/domain/browserScenarioNormalization.ts b/src/domain/browserScenarioNormalization.ts new file mode 100644 index 000000000..24f6a2f08 --- /dev/null +++ b/src/domain/browserScenarioNormalization.ts @@ -0,0 +1,73 @@ +import { createHash } from "node:crypto"; + +import canonicalize from "canonicalize"; + +import type { + BrowserScenarioArtifactKind, + BrowserScenarioDiff, + BrowserScenarioNormalization, + BrowserScenarioNormalizationRule, +} from "./browserScenarioDiffValues.js"; + +const BUILT_IN_RULES = [ + "ignore_step_elapsed_ms", + "ignore_event_sequence", + "ignore_event_step_index", + "compare_screenshot_content_digest", + "compare_normalized_text_content", +] as const; + +/** Canonically order and commit a parsed browser scenario normalization policy. */ +export const commitBrowserScenarioNormalization = ( + input: BrowserScenarioNormalization, +): BrowserScenarioDiff["normalization"] => { + const rules = [...input.rules] + .map((rule) => ({ + ...rule, + artifacts: [...rule.artifacts].sort(), + })) + .sort((left, right) => + left.rule_id < right.rule_id ? -1 : left.rule_id > right.rule_id ? 1 : 0, + ); + const commitment = { built_in_rules: [...BUILT_IN_RULES], rules }; + return { ...commitment, sha256: digestCanonicalJson(commitment) }; +}; + +/** Digest one artifact after applying committed exact-literal rules. */ +export const digestNormalizedScenarioValue = ( + value: unknown, + artifact: BrowserScenarioArtifactKind, + rules: readonly BrowserScenarioNormalizationRule[], +): string => digestCanonicalJson(normalize(value, artifact, rules)); + +/** Compute a canonical SHA-256 commitment for JSON-compatible domain data. */ +export const digestCanonicalJson = (value: unknown): string => { + const encoded = canonicalize(value); + if (encoded === undefined) throw new TypeError("Expected canonical JSON"); + return createHash("sha256").update(encoded).digest("hex"); +}; + +const normalize = ( + value: unknown, + artifact: BrowserScenarioArtifactKind, + rules: readonly BrowserScenarioNormalizationRule[], +): unknown => { + if (artifact === "screenshot") return value; + if (typeof value === "string") + return rules + .filter(({ artifacts }) => artifacts.includes(artifact)) + .reduce( + (normalized, rule) => + normalized.split(rule.match).join(rule.replacement), + value, + ); + if (Array.isArray(value)) + return value.map((item) => normalize(item, artifact, rules)); + if (typeof value !== "object" || value === null) return value; + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + normalize(item, artifact, rules), + ]), + ); +}; diff --git a/src/domain/browserScenarioValues.ts b/src/domain/browserScenarioValues.ts new file mode 100644 index 000000000..28c23d811 --- /dev/null +++ b/src/domain/browserScenarioValues.ts @@ -0,0 +1,424 @@ +import { z } from "zod"; + +import { + browserAllowedOriginsSchema, + browserEndpointSchema, + browserOriginSchema, +} from "./browserObservation.js"; + +export const BROWSER_SCENARIO_LIMITS = { + actions: 128, + secrets: 64, + storageEntries: 512, + replayRoutes: 256, + durationMs: 300_000, + actionTimeoutMs: 60_000, +} as const; + +export const scenarioIdentifierSchema = z + .string() + .regex(/^[A-Za-z][A-Za-z0-9._-]{0,63}$/u); + +const absoluteExecutablePathSchema = z + .string() + .min(1) + .max(4_096) + .refine( + (value) => + value.startsWith("/") || + /^[A-Za-z]:[\\/]/u.test(value) || + value.startsWith("\\\\"), + "Expected an absolute browser executable path", + ); + +const browserScenarioBaseUrlSchema = z + .string() + .min(1) + .max(65_536) + .transform((value, context) => { + let url: URL; + try { + url = new URL(value); + } catch { + context.addIssue({ code: "custom", message: "Invalid browser URL" }); + return z.NEVER; + } + if ( + (url.protocol !== "http:" && url.protocol !== "https:") || + url.username !== "" || + url.password !== "" || + url.search !== "" || + url.hash !== "" + ) { + context.addIssue({ + code: "custom", + message: + "Browser URLs must be HTTP(S), omit credentials, and declare query values separately", + }); + return z.NEVER; + } + return url.toString(); + }); + +export const browserScenarioValueSchema = z.discriminatedUnion("source", [ + z.strictObject({ + source: z.literal("literal"), + value: z.string().max(65_536), + classification: z.literal("public"), + }), + z.strictObject({ + source: z.literal("secret"), + secret_id: scenarioIdentifierSchema, + }), +]); +export type BrowserScenarioValue = z.infer; + +const queryEntrySchema = z.strictObject({ + name: z.string().min(1).max(256), + value: browserScenarioValueSchema, +}); + +/** URL whose values remain explicit public literals or declared secret references. */ +export const browserScenarioUrlSchema = z.strictObject({ + url: browserScenarioBaseUrlSchema, + query: z.array(queryEntrySchema).max(64).default([]), +}); +export type BrowserScenarioUrl = z.infer; + +export const browserScenarioBrowserSchema = z.discriminatedUnion("mode", [ + z.strictObject({ + mode: z.literal("launch"), + executable_path: absoluteExecutablePathSchema, + headless: z.literal(true), + user_data: z.literal("temporary-owned"), + cleanup: z.literal("close-and-delete-profile"), + }), + z.strictObject({ + mode: z.literal("connect"), + cdp_endpoint: browserEndpointSchema, + target_id: z.string().trim().min(1).max(256), + ownership: z.literal("external"), + cleanup: z.literal("disconnect-only"), + }), +]); + +export const browserScenarioEnvironmentSchema = z.strictObject({ + viewport: z.strictObject({ + width: z.number().int().min(320).max(7_680), + height: z.number().int().min(240).max(4_320), + device_scale_factor: z.number().min(0.5).max(4).default(1), + }), + locale: z + .string() + .min(2) + .max(64) + .regex(/^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$/u), + timezone: z + .string() + .min(1) + .max(128) + .regex(/^[A-Za-z0-9_+-]+(?:\/[A-Za-z0-9_+-]+)*$/u), + color_scheme: z.enum(["light", "dark", "no-preference"]), + reduced_motion: z.enum(["reduce", "no-preference"]), + service_workers: z.literal("block"), +}); + +const locatorSchema = z.discriminatedUnion("kind", [ + z.strictObject({ + kind: z.literal("test_id"), + value: z.string().min(1).max(512), + }), + z.strictObject({ + kind: z.literal("role"), + role: z.enum([ + "button", + "checkbox", + "combobox", + "dialog", + "link", + "listbox", + "menuitem", + "option", + "radio", + "slider", + "spinbutton", + "switch", + "tab", + "textbox", + ]), + name: z.string().min(1).max(1_024), + exact: z.literal(true), + }), + z.strictObject({ + kind: z.literal("css"), + selector: z.string().min(1).max(4_096), + approved: z.literal(true), + }), +]); + +const stepBase = { + step_id: scenarioIdentifierSchema, + timeout_ms: z + .number() + .int() + .min(1) + .max(BROWSER_SCENARIO_LIMITS.actionTimeoutMs) + .optional(), +}; + +export const browserScenarioActionSchema = z.discriminatedUnion("action", [ + z.strictObject({ + ...stepBase, + action: z.literal("goto"), + destination: browserScenarioUrlSchema, + wait_until: z.enum(["commit", "domcontentloaded", "load"]), + }), + z.strictObject({ + ...stepBase, + action: z.literal("click"), + locator: locatorSchema, + button: z.enum(["left", "middle", "right"]).default("left"), + click_count: z.number().int().min(1).max(3).default(1), + }), + z.strictObject({ + ...stepBase, + action: z.literal("fill"), + locator: locatorSchema, + value: browserScenarioValueSchema, + }), + z.strictObject({ + ...stepBase, + action: z.literal("press"), + locator: locatorSchema, + key: z.string().min(1).max(64), + }), + z.strictObject({ + ...stepBase, + action: z.literal("select_option"), + locator: locatorSchema, + value: browserScenarioValueSchema, + }), + z.strictObject({ + ...stepBase, + action: z.enum(["check", "uncheck"]), + locator: locatorSchema, + }), + z.strictObject({ + ...stepBase, + action: z.literal("wait_for"), + locator: locatorSchema, + state: z.enum(["attached", "detached", "visible", "hidden"]), + }), + z.strictObject({ + step_id: scenarioIdentifierSchema, + action: z.literal("wait_for_timeout"), + duration_ms: z + .number() + .int() + .min(1) + .max(BROWSER_SCENARIO_LIMITS.actionTimeoutMs), + }), +]); +export type BrowserScenarioAction = z.infer; + +const storageEntrySchema = z.strictObject({ + name: z.string().min(1).max(1_024), + value: browserScenarioValueSchema, +}); + +export const browserScenarioStorageSchema = z.strictObject({ + cookies: z + .array( + z.strictObject({ + name: z.string().min(1).max(1_024), + value: browserScenarioValueSchema, + destination: browserScenarioUrlSchema, + http_only: z.boolean(), + secure: z.boolean(), + same_site: z.enum(["Strict", "Lax", "None"]), + }), + ) + .max(128) + .default([]), + local_storage: z + .array( + z.strictObject({ + origin: browserOriginSchema, + entries: z.array(storageEntrySchema).max(128), + }), + ) + .max(32) + .default([]), + session_storage: z + .array( + z.strictObject({ + origin: browserOriginSchema, + entries: z.array(storageEntrySchema).max(128), + }), + ) + .max(32) + .default([]), +}); + +const headerNameSchema = z + .string() + .min(1) + .max(256) + .regex(/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u) + .transform((value) => value.toLowerCase()); + +const replayHeaderSchema = z.strictObject({ + name: headerNameSchema, + value: browserScenarioValueSchema, +}); + +const replayResponseSchema = z.discriminatedUnion("kind", [ + z.strictObject({ + kind: z.literal("response"), + status: z.number().int().min(100).max(599), + headers: z.array(replayHeaderSchema).max(64).default([]), + body: browserScenarioValueSchema.optional(), + }), + z.strictObject({ + kind: z.literal("redirect"), + status: z.union([ + z.literal(301), + z.literal(302), + z.literal(303), + z.literal(307), + z.literal(308), + ]), + destination: browserScenarioUrlSchema, + }), +]); + +const replayRouteSchema = z.strictObject({ + route_id: scenarioIdentifierSchema, + method: z.enum(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]), + request: browserScenarioUrlSchema, + response: replayResponseSchema, +}); + +export const browserScenarioRequestReplaySchema = z.discriminatedUnion("mode", [ + z.strictObject({ mode: z.literal("disabled") }), + z.strictObject({ + mode: z.literal("exact"), + unmatched: z.enum(["abort", "passthrough-approved-origins"]), + routes: z + .array(replayRouteSchema) + .min(1) + .max(BROWSER_SCENARIO_LIMITS.replayRoutes), + }), +]); + +export const browserScenarioSecretSchema = z.strictObject({ + secret_id: scenarioIdentifierSchema, + environment_variable: z.string().regex(/^[A-Z_][A-Z0-9_]{0,127}$/u), + purpose: z.enum(["input", "storage", "request-replay"]), + redaction: z.literal("replace-with-secret-reference"), +}); + +const REQUIRED_REDACTED_HEADERS = [ + "authorization", + "cookie", + "proxy-authorization", + "set-cookie", +] as const; + +const normalizedNames = (maximum: number) => + z + .array( + z + .string() + .trim() + .min(1) + .max(256) + .transform((value) => value.toLowerCase()), + ) + .max(maximum) + .transform((values) => [...new Set(values)].sort()); + +export const browserScenarioRedactionSchema = z + .strictObject({ + secret_values: z.literal("replace-with-secret-reference"), + query_parameter_names: normalizedNames(128).default([]), + header_names: normalizedNames(128).default([...REQUIRED_REDACTED_HEADERS]), + }) + .superRefine(({ header_names: names }, context) => { + for (const required of REQUIRED_REDACTED_HEADERS) + if (!names.includes(required)) + context.addIssue({ + code: "custom", + path: ["header_names"], + message: `Required credential header ${required} must be redacted`, + }); + }); + +const snapshotKindSchema = z.enum([ + "screenshot", + "dom", + "accessibility", + "url", + "history", + "storage", +]); + +export const browserScenarioCaptureSchema = z.strictObject({ + after_each_step: z.array(snapshotKindSchema).max(6), + at_end: z.array(snapshotKindSchema).max(6), + events: z.array( + z.enum([ + "console", + "page-errors", + "network", + "websockets", + "frames", + "workers", + "popups", + "downloads", + ]), + ), +}); + +export const browserScenarioCaptureLimitsSchema = z.strictObject({ + max_duration_ms: z + .number() + .int() + .min(100) + .max(BROWSER_SCENARIO_LIMITS.durationMs), + action_timeout_ms: z + .number() + .int() + .min(1) + .max(BROWSER_SCENARIO_LIMITS.actionTimeoutMs), + navigation_timeout_ms: z + .number() + .int() + .min(1) + .max(BROWSER_SCENARIO_LIMITS.actionTimeoutMs), + max_events: z.number().int().min(1).max(100_000), + max_frames: z.number().int().min(1).max(1_000), + max_workers: z.number().int().min(0).max(1_000), + max_popups: z.number().int().min(0).max(100), + max_websockets: z.number().int().min(0).max(10_000), + max_dom_nodes: z.number().int().min(1).max(100_000), + max_accessibility_nodes: z.number().int().min(1).max(100_000), + max_screenshots: z.number().int().min(0).max(256), + max_screenshot_bytes: z + .number() + .int() + .min(1) + .max(8 * 1_024 * 1_024), + max_storage_entries: z + .number() + .int() + .min(0) + .max(BROWSER_SCENARIO_LIMITS.storageEntries), + max_total_metadata_bytes: z + .number() + .int() + .min(1_024) + .max(64 * 1_024 * 1_024), +}); + +export const browserScenarioAllowedOriginsSchema = browserAllowedOriginsSchema; diff --git a/src/domain/errors.ts b/src/domain/errors.ts index 7e30121c8..cafdb2479 100644 --- a/src/domain/errors.ts +++ b/src/domain/errors.ts @@ -1,4 +1,10 @@ import type { JsonValue } from "./jsonValue.js"; +import type { + BrowserObservationFailureReason, + BrowserObservationOperation, +} from "./browserObservationErrors.js"; + +export type { BrowserObservationOperation } from "./browserObservationErrors.js"; import type { MissingPermissionScope, PermissionRequest, @@ -258,34 +264,13 @@ export class ProviderAdapterError extends AnalysisError { } } -/** Public browser and Electron operations that can fail at a CDP boundary. */ -export type BrowserObservationOperation = - | "list_browser_targets" - | "inspect_web_page" - | "analyze_web_bundle" - | "observe_web_session" - | "discover_webmcp_tools" - | "compare_web_captures" - | "capture_web_screenshot" - | "compare_web_screenshots" - | "list_electron_targets" - | "inspect_electron_page"; - /** A bounded passive browser observation failed at its CDP boundary. */ export class BrowserObservationError extends AnalysisError { readonly _tag = "BrowserObservationError"; constructor( readonly operation: BrowserObservationOperation, - readonly reason: - | "endpoint_unreachable" - | "invalid_endpoint_response" - | "target_not_found" - | "target_not_allowed" - | "target_changed" - | "protocol_error" - | "disconnected" - | "payload_limit", + readonly reason: BrowserObservationFailureReason, options?: ErrorOptions, ) { super(`Browser observation ${operation} failed: ${reason}`, options); @@ -299,6 +284,7 @@ export class ArtifactOperationError extends AnalysisError { constructor( readonly operation: | "inventory_artifact" + | "inspect_artifact" | "extract_artifact" | "analyze_javascript_application", readonly reason: @@ -441,6 +427,7 @@ export type HopperDiagnosticType = | "remote" | "authorization" | "invalid_request" + | "capability_unavailable" | "bridge_exception"; /** Hopper's JSON-RPC endpoint returned an expected remote error response. */ diff --git a/src/domain/javascriptRuntimeObservation.ts b/src/domain/javascriptRuntimeObservation.ts new file mode 100644 index 000000000..2f3f82f98 --- /dev/null +++ b/src/domain/javascriptRuntimeObservation.ts @@ -0,0 +1,216 @@ +import { isAbsolute, resolve } from "node:path"; + +import { z } from "zod"; + +import { + browserEndpointSchema, + browserOriginSchema, +} from "./browserObservation.js"; + +const boundedTextSchema = z.string().min(1).max(4_096); + +const runtimeFileRootsSchema = z + .array( + z + .string() + .min(1) + .max(16_384) + .refine(isAbsolute, "Runtime file roots must be absolute paths") + .overwrite(resolve), + ) + .max(32) + .overwrite((roots) => [...new Set(roots)].sort()) + .default([]); + +const runtimeOriginsSchema = z + .array(browserOriginSchema) + .max(32) + .overwrite((origins) => [...new Set(origins)].sort()) + .default([]); + +const approvedRuntimeScope = { + inspector_endpoint: browserEndpointSchema, + allowed_file_roots: runtimeFileRootsSchema, + allowed_origins: runtimeOriginsSchema, + approved: z.literal(true), +}; + +const requireRuntimeScope = ( + input: { + readonly allowed_file_roots: readonly string[]; + readonly allowed_origins: readonly string[]; + }, + context: z.RefinementCtx, +): void => { + if ( + input.allowed_file_roots.length === 0 && + input.allowed_origins.length === 0 + ) + context.addIssue({ + code: "custom", + path: ["allowed_file_roots"], + message: "At least one exact file root or HTTP(S) origin is required", + }); +}; + +/** Input for listing approved Node/Electron V8 Inspector targets. */ +export const listJavaScriptRuntimeTargetsInputSchema = z + .strictObject({ + ...approvedRuntimeScope, + offset: z.number().int().min(0).default(0), + limit: z.number().int().min(1).max(200).default(100), + }) + .superRefine(requireRuntimeScope); +export type ListJavaScriptRuntimeTargetsInput = z.infer< + typeof listJavaScriptRuntimeTargetsInputSchema +>; + +export const javascriptRuntimeKindSchema = z.enum([ + "node", + "electron-main", + "electron-preload", + "electron-renderer", +]); + +export const javascriptRuntimeObservationLimitsSchema = z.strictObject({ + max_events: z.number().int().min(1).max(50_000).default(10_000), + max_scripts: z.number().int().min(1).max(10_000).default(2_000), + max_execution_contexts: z.number().int().min(1).max(5_000).default(1_000), + max_location_bytes: z.number().int().min(64).max(65_536).default(16_384), + max_total_metadata_bytes: z + .number() + .int() + .min(1_024) + .max(32 * 1_024 * 1_024) + .default(4 * 1_024 * 1_024), +}); + +/** Input for one bounded, attach-only V8 Inspector observation. */ +export const observeJavaScriptRuntimeInputSchema = z + .strictObject({ + ...approvedRuntimeScope, + target_id: z.string().trim().min(1).max(256), + runtime_kind: javascriptRuntimeKindSchema, + observation_ms: z.number().int().min(0).max(10_000).default(100), + limits: javascriptRuntimeObservationLimitsSchema.default({ + max_events: 10_000, + max_scripts: 2_000, + max_execution_contexts: 1_000, + max_location_bytes: 16_384, + max_total_metadata_bytes: 4 * 1_024 * 1_024, + }), + }) + .superRefine(requireRuntimeScope); +export type ObserveJavaScriptRuntimeInput = z.infer< + typeof observeJavaScriptRuntimeInputSchema +>; + +export const javascriptRuntimeVersionSchema = z.strictObject({ + product: z.string().min(1).max(1_024), + protocol_version: z.string().min(1).max(100), + v8_version: z.string().max(1_024).nullable(), +}); + +export const javascriptRuntimeLocationSchema = z.discriminatedUnion("kind", [ + z.strictObject({ + kind: z.literal("file"), + file_path: z.string().min(1).max(16_384), + }), + z.strictObject({ + kind: z.literal("url"), + origin: z.string().min(1).max(2_048), + sanitized_url: z.string().min(1).max(65_536), + }), + z.strictObject({ + kind: z.literal("builtin"), + specifier: z.string().min(1).max(4_096), + }), +]); +export type JavaScriptRuntimeLocation = z.infer< + typeof javascriptRuntimeLocationSchema +>; + +const javascriptRuntimeTargetSchema = z.strictObject({ + target_id: z.string().min(1).max(256), + protocol_type: z.string().min(1).max(100), + attached: z.boolean(), + location: javascriptRuntimeLocationSchema, +}); + +/** Root/origin-filtered V8 Inspector target inventory. */ +export const javascriptRuntimeTargetListSchema = z.strictObject({ + schema_version: z.literal(1), + runtime: javascriptRuntimeVersionSchema, + targets: z.strictObject({ + items: z.array(javascriptRuntimeTargetSchema), + offset: z.number().int().min(0), + limit: z.number().int().min(1), + total: z.number().int().min(0), + next_offset: z.number().int().min(0).nullable(), + has_more: z.boolean(), + }), + excluded: z.strictObject({ + outside_file_roots: z.number().int().min(0), + outside_origins: z.number().int().min(0), + unsupported_location: z.number().int().min(0), + unconnectable: z.number().int().min(0), + }), + limitations: z.array(boundedTextSchema).max(100), +}); +export type JavaScriptRuntimeTargetList = z.infer< + typeof javascriptRuntimeTargetListSchema +>; + +const javascriptRuntimeScriptSchema = z.strictObject({ + script_key: z.string().regex(/^v8_script_[a-f0-9]{64}$/u), + location: javascriptRuntimeLocationSchema, + execution_context_key: z.string().max(64).nullable(), + cdp_hash: z.string().max(512).nullable(), + length: z.number().int().min(0), + is_module: z.boolean(), + status: z.literal("observed-loaded"), +}); + +const javascriptRuntimeContextSchema = z.strictObject({ + context_key: z.string().min(1).max(64), + state: z.enum(["created", "destroyed", "cleared"]), + name: z.string().max(1_024).nullable(), + origin: z.string().max(4_096).nullable(), +}); + +/** Deterministic passive script/context snapshot from one bounded window. */ +export const javascriptRuntimeObservationSchema = z.strictObject({ + schema_version: z.literal(1), + runtime: javascriptRuntimeVersionSchema, + target: javascriptRuntimeTargetSchema.extend({ + runtime_kind: javascriptRuntimeKindSchema, + runtime_kind_authority: z.literal("caller-declared-unverified"), + }), + capture: z.strictObject({ + observation_ms: z.number().int().min(0), + events_observed: z.number().int().min(0), + events_retained: z.number().int().min(0), + events_dropped: z.number().int().min(0), + metadata_bytes_retained: z.number().int().min(0), + truncated: z.boolean(), + truncation_reasons: z.array(boundedTextSchema).max(20), + }), + scripts: z.strictObject({ + items: z.array(javascriptRuntimeScriptSchema), + observed_total: z.number().int().min(0), + excluded: z.strictObject({ + outside_file_roots: z.number().int().min(0), + outside_origins: z.number().int().min(0), + unsupported_location: z.number().int().min(0), + invalid_protocol_value: z.number().int().min(0), + }), + }), + execution_contexts: z.array(javascriptRuntimeContextSchema), + directly_observed: z.array(boundedTextSchema).max(20), + unavailable_without_instrumentation: z.array(boundedTextSchema).max(20), + unknowns: z.array(boundedTextSchema).max(100), + limitations: z.array(boundedTextSchema).max(100), +}); +export type JavaScriptRuntimeObservation = z.infer< + typeof javascriptRuntimeObservationSchema +>; diff --git a/src/domain/javascriptRuntimeReconciliationParsing.ts b/src/domain/javascriptRuntimeReconciliationParsing.ts index 448afb2f0..eeb51a900 100644 --- a/src/domain/javascriptRuntimeReconciliationParsing.ts +++ b/src/domain/javascriptRuntimeReconciliationParsing.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { isAbsolute, relative } from "node:path"; import canonicalize from "canonicalize"; import { z } from "zod"; @@ -6,14 +7,24 @@ import { z } from "zod"; import { browserAllowedOriginsSchema, browserEndpointSchema, + browserOriginSchema, webPageInspectionSchema, type WebPageInspection, } from "./browserObservation.js"; +import { + classifyBrowserCompleteness, + type BrowserCompleteness, +} from "./browserCompleteness.js"; import { electronFileRootsSchema, electronPageInspectionSchema, type ElectronPageInspection, } from "./electronObservation.js"; +import { + javascriptRuntimeObservationSchema, + javascriptRuntimeKindSchema, + type JavaScriptRuntimeObservation, +} from "./javascriptRuntimeObservation.js"; import { parseEvidence, type Evidence } from "./evidence.js"; import { javascriptApplicationAnalysisResultSchema, @@ -45,6 +56,39 @@ interface RuntimeCaptureBase { readonly scriptsCompleteWithinScope: boolean; } +interface NormalizedV8Inspection { + readonly target: + | { + readonly target_id: string; + readonly type: string; + readonly title: string; + readonly attached: boolean; + readonly file_path: string; + } + | { + readonly target_id: string; + readonly type: string; + readonly title: string; + readonly attached: boolean; + readonly url: string; + }; + readonly frames: readonly []; + readonly scripts: { + readonly items: readonly (Readonly<{ + script_key: string; + frame_id: string | null; + cdp_hash: string; + length: number; + is_module: boolean; + language: null; + source: { readonly included: false; readonly reason: string }; + }> & + ({ readonly file_path: string } | { readonly url: string }))[]; + }; + readonly workers: readonly []; + readonly completeness: BrowserCompleteness; +} + export type ParsedRuntimeCapture = | (RuntimeCaptureBase & { readonly kind: "browser"; @@ -53,6 +97,10 @@ export type ParsedRuntimeCapture = | (RuntimeCaptureBase & { readonly kind: "electron"; readonly inspection: ElectronPageInspection; + }) + | (RuntimeCaptureBase & { + readonly kind: "v8-inspector"; + readonly inspection: NormalizedV8Inspection; }); /** Parse and semantically bind every static analysis Evidence layer. */ @@ -184,11 +232,157 @@ const parseRuntimeCapture = (input: Evidence): ParsedRuntimeCapture => { scriptsCompleteWithinScope: scriptsComplete(inspection.completeness), }; } + if (evidence.operation === "observe_javascript_runtime") { + assertEvidenceIdentity(evidence, { + operation: "observe_javascript_runtime", + predicate: "rea.javascript-runtime-observation/v1", + providerId: "rea-v8-inspector", + providerName: "REA passive Node/Electron V8 Inspector provider", + providerVersion: "1", + authority: "external-service", + confidence: "observed", + }); + const result = javascriptRuntimeObservationSchema.parse( + evidence.normalized_result, + ); + assertV8RuntimeParameters(evidence, result); + const inspection = normalizeV8Inspection(result); + return { + kind: "v8-inspector", + evidence, + inspection, + captureSha256: digestCanonical(result), + scriptsCompleteWithinScope: false, + }; + } throw new TypeError( - "Runtime reconciliation requires inspect_web_page or inspect_electron_page Evidence", + "Runtime reconciliation requires inspect_web_page, inspect_electron_page, or observe_javascript_runtime Evidence", ); }; +const assertV8RuntimeParameters = ( + evidence: Evidence, + result: JavaScriptRuntimeObservation, +): void => { + const parameters = z + .object({ + inspector_endpoint: browserEndpointSchema, + allowed_file_roots: z + .array(z.string().min(1).max(16_384).refine(isAbsolute)) + .max(32), + allowed_origins: z.array(browserOriginSchema).max(32), + target_id: z.string().trim().min(1).max(256), + runtime_kind: javascriptRuntimeKindSchema, + }) + .passthrough() + .parse(evidence.parameters); + if ( + parameters.target_id !== result.target.target_id || + parameters.runtime_kind !== result.target.runtime_kind + ) + throw new TypeError( + "Runtime Evidence target or declared role disagrees with its result", + ); + for (const location of [ + result.target.location, + ...result.scripts.items.map(({ location }) => location), + ]) + if ( + (location.kind === "file" && + !parameters.allowed_file_roots.some((root) => + isWithinRoot(root, location.file_path), + )) || + (location.kind === "url" && + !parameters.allowed_origins.includes(location.origin)) || + (location.kind === "builtin" && !location.specifier.startsWith("node:")) + ) + throw new TypeError( + "Runtime Evidence contains a location outside its recorded scope", + ); +}; + +const isWithinRoot = (root: string, path: string): boolean => { + const remainder = relative(root, path); + return ( + remainder === "" || (!remainder.startsWith("..") && !isAbsolute(remainder)) + ); +}; + +const normalizeV8Inspection = ( + result: JavaScriptRuntimeObservation, +): NormalizedV8Inspection => ({ + target: { + target_id: result.target.target_id, + type: result.target.protocol_type, + title: result.target.runtime_kind, + attached: result.target.attached, + ...runtimeLocation(result.target.location), + }, + frames: [], + scripts: { + items: result.scripts.items.map((script) => ({ + script_key: script.script_key, + frame_id: script.execution_context_key, + ...runtimeLocation(script.location), + cdp_hash: script.cdp_hash ?? "", + length: script.length, + is_module: script.is_module, + language: null, + source: { + included: false, + reason: "V8 Inspector source capture is outside passive authority.", + }, + })), + }, + workers: [], + completeness: classifyBrowserCompleteness({ + policyFilteredSections: new Set( + excludedV8Scripts(result) > 0 ? ["scripts"] : [], + ), + attachLimitedSections: new Set([ + "frames", + "scripts", + "script_sources", + "workers", + ]), + truncatedSections: new Set(result.capture.truncated ? ["scripts"] : []), + unavailableSections: new Set(["frames", "script_sources", "workers"]), + excluded: + excludedV8Scripts(result) === 0 + ? [] + : [ + { + section: "scripts", + reason: "out_of_target_scope", + count: excludedV8Scripts(result), + }, + ], + droppedEvents: { + scripts: result.capture.events_dropped, + network_requests: 0, + console_events: 0, + websocket_connections: 0, + websocket_frames: 0, + webmcp_tools: 0, + timeline_events: 0, + }, + }), +}); + +const runtimeLocation = ( + location: JavaScriptRuntimeObservation["target"]["location"], +): { readonly file_path: string } | { readonly url: string } => { + if (location.kind === "file") return { file_path: location.file_path }; + if (location.kind === "url") return { url: location.sanitized_url }; + return { url: location.specifier }; +}; + +const excludedV8Scripts = (result: JavaScriptRuntimeObservation): number => + Object.values(result.scripts.excluded).reduce( + (total, count) => total + count, + 0, + ); + const assertRuntimeParameters = ( evidence: Evidence, expected: diff --git a/src/domain/javascriptRuntimeReconciliationResult.ts b/src/domain/javascriptRuntimeReconciliationResult.ts index 05da848d7..dbdc22e1d 100644 --- a/src/domain/javascriptRuntimeReconciliationResult.ts +++ b/src/domain/javascriptRuntimeReconciliationResult.ts @@ -88,10 +88,10 @@ const captureSummaries = ( kind: capture.kind, target_node_id: targetNode.node_id, target_key: capture.inspection.target.target_id, - target_location: - capture.kind === "browser" - ? capture.inspection.target.url.slice(0, 4_096) - : capture.inspection.target.file_path.slice(0, 4_096), + target_location: ("url" in capture.inspection.target + ? capture.inspection.target.url + : capture.inspection.target.file_path + ).slice(0, 4_096), frames: capture.inspection.frames.length, scripts: capture.inspection.scripts.items.length, workers: capture.inspection.workers.length, diff --git a/src/domain/javascriptRuntimeReconciliationRuntime.ts b/src/domain/javascriptRuntimeReconciliationRuntime.ts index 9f9fdb22f..bfebc8406 100644 --- a/src/domain/javascriptRuntimeReconciliationRuntime.ts +++ b/src/domain/javascriptRuntimeReconciliationRuntime.ts @@ -286,8 +286,7 @@ const runtimeEvidence = ( version: capture.evidence.provider.version ?? (capture.kind === "browser" ? "2" : "1"), - operation: - capture.kind === "browser" ? "inspect_web_page" : "inspect_electron_page", + operation: capture.evidence.operation, executable_sha256: null, }, coverage: runtimeSectionCoverage(capture, input.section), @@ -363,8 +362,7 @@ const runtimeRelationshipEvidence = ( version: capture.evidence.provider.version ?? (capture.kind === "browser" ? "2" : "1"), - operation: - capture.kind === "browser" ? "inspect_web_page" : "inspect_electron_page", + operation: capture.evidence.operation, executable_sha256: null, }, coverage: runtimeSectionCoverage(capture, entity.section), diff --git a/src/domain/javascriptRuntimeReconciliationSchemas.ts b/src/domain/javascriptRuntimeReconciliationSchemas.ts index b31a7250b..50ce85fcf 100644 --- a/src/domain/javascriptRuntimeReconciliationSchemas.ts +++ b/src/domain/javascriptRuntimeReconciliationSchemas.ts @@ -170,7 +170,7 @@ const layerSummarySchema = z.strictObject({ const captureSummarySchema = z.strictObject({ evidence_id: evidenceIdSchema, capture_sha256: digestSchema, - kind: z.enum(["browser", "electron"]), + kind: z.enum(["browser", "electron", "v8-inspector"]), target_node_id: nodeIdSchema, target_key: boundedTextSchema, target_location: boundedTextSchema, diff --git a/src/domain/javascriptSemanticAnalysis.ts b/src/domain/javascriptSemanticAnalysis.ts index 6e39f758d..aa71b493c 100644 --- a/src/domain/javascriptSemanticAnalysis.ts +++ b/src/domain/javascriptSemanticAnalysis.ts @@ -35,7 +35,7 @@ import { collectSemanticReturns, resolveSemanticModuleCallables, } from "./javascriptSemanticReturns.js"; -import { collectJavaScriptSemanticCalls } from "./javascriptSemanticCalls.js"; +import { collectJavaScriptDerivedSemantics } from "./javascriptSemanticDerivedAnalysis.js"; import { compareCodePoints, propertyName, @@ -87,16 +87,21 @@ export const analyzeJavaScriptSemantics = ( const bindings = immutableSemanticBindings(state); const callables = collectSemanticReturns(file.program, state, parserPartial); const moduleLinks = resolveSemanticModuleCallables(state, callables); - const calls = collectJavaScriptSemanticCalls(file.program, state, callables); + const derived = collectJavaScriptDerivedSemantics( + file.program, + state, + callables, + parserPartial, + ); return { schema: "JavaScriptSemanticIR", - schemaVersion: 3, + schemaVersion: 4, scopes: immutableSemanticScopes(state), bindings, callables, references, moduleLinks, - ...calls, + ...derived, coverage: { status: state.limitsReached.size > 0 @@ -119,6 +124,7 @@ export const analyzeJavaScriptSemantics = ( "Values and aliases were recovered from inert syntax only; no JavaScript was executed.", "Return sites include only direct callable returns; nested callable returns remain separate.", "Local call, argument, return, and closure relations are static candidates and do not prove runtime invocation.", + "Promise ownership covers explicit unshadowed Promise construction, static factories, aggregation, chaining, and await syntax only.", "Cross-function mutation and dynamic property resolution remain unknown.", ], }; diff --git a/src/domain/javascriptSemanticAsyncEffects.ts b/src/domain/javascriptSemanticAsyncEffects.ts new file mode 100644 index 000000000..bfbc72742 --- /dev/null +++ b/src/domain/javascriptSemanticAsyncEffects.ts @@ -0,0 +1,374 @@ +import * as t from "@babel/types"; + +import type { + JavaScriptSemanticCallable, + JavaScriptSemanticEventOperation, + JavaScriptSemanticTimerOperation, +} from "./javascriptSemanticIr.js"; +import { + reachSemanticLimit, + semanticCallableIdForNode, + semanticStaticPropertyName, +} from "./javascriptSemanticProjection.js"; +import { + resolveSemanticBindingState, + semanticResolutionBlocked, + type JavaScriptSemanticAnalysisState, + type JavaScriptSemanticBindingState, +} from "./javascriptSemanticState.js"; +import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; +import { range } from "./javascriptStaticAnalysisHelpers.js"; + +const EVENT_REGISTER_METHODS = [ + "on", + "once", + "addListener", + "prependListener", + "prependOnceListener", +] as const; +const EVENT_REMOVE_METHODS = [ + "off", + "removeListener", + "removeAllListeners", +] as const; +const EVENT_METHODS = [ + ...EVENT_REGISTER_METHODS, + ...EVENT_REMOVE_METHODS, + "emit", +] as const; +const TIMER_SCHEDULE_METHODS = [ + "setTimeout", + "setInterval", + "setImmediate", +] as const; +const TIMER_CANCEL_METHODS = [ + "clearTimeout", + "clearInterval", + "clearImmediate", +] as const; + +interface AsyncEffectAnalysis { + readonly eventOperations: readonly JavaScriptSemanticEventOperation[]; + readonly timerOperations: readonly JavaScriptSemanticTimerOperation[]; +} + +interface TimerCandidate { + readonly node: t.CallExpression | t.OptionalCallExpression; + readonly method: JavaScriptSemanticTimerOperation["method"]; + readonly kind: JavaScriptSemanticTimerOperation["kind"]; + readonly ownerCallableId: string | null; + readonly handleBindingId: string | null; +} + +interface TimerCollectionInput { + readonly node: t.CallExpression | t.OptionalCallExpression; + readonly parent: t.Node | null; + readonly ownerCallableId: string | null; + readonly state: JavaScriptSemanticAnalysisState; + readonly output: TimerCandidate[]; +} + +/** Recover explicit EventEmitter and Node timer candidates from inert syntax. */ +export const collectJavaScriptSemanticAsyncEffects = ( + program: t.Program, + state: JavaScriptSemanticAnalysisState, + callables: readonly JavaScriptSemanticCallable[], +): AsyncEffectAnalysis => { + const events: JavaScriptSemanticEventOperation[] = []; + const timers: TimerCandidate[] = []; + const callableIds = new Set(callables.map(({ callableId }) => callableId)); + const callableStack: string[] = []; + traverseJavaScriptAst(program, { + enter: (node, parent) => { + const callableId = semanticCallableIdForNode(node); + if (callableId !== null && callableIds.has(callableId)) + callableStack.push(callableId); + if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) + return; + const ownerCallableId = callableStack.at(-1) ?? null; + collectEvent(node, ownerCallableId, state, events); + collectTimer({ node, parent, ownerCallableId, state, output: timers }); + }, + exit: (node) => { + const callableId = semanticCallableIdForNode(node); + if (callableId !== null && callableStack.at(-1) === callableId) + callableStack.pop(); + }, + }); + return { + eventOperations: events, + timerOperations: resolveTimers(timers), + }; +}; + +const collectEvent = ( + node: t.CallExpression | t.OptionalCallExpression, + ownerCallableId: string | null, + state: JavaScriptSemanticAnalysisState, + output: JavaScriptSemanticEventOperation[], +): void => { + const member = memberCallee(node); + if (member === null) return; + const method = EVENT_METHODS.find( + (candidate) => + candidate === + semanticStaticPropertyName(member.property, member.computed), + ); + if (method === undefined) return; + const kind = eventKind(method); + if (output.length >= state.limits.maxEventOperations) { + reachSemanticLimit(state, "maxEventOperations"); + return; + } + const eventArgument = node.arguments[0]; + const listenerArgument = node.arguments[1]; + const emitter = emitterIdentity(member.object, state); + const eventName = literalString(eventArgument); + output.push({ + eventId: `event:${kind}:${String(node.start ?? -1)}:${String(node.end ?? -1)}`, + kind, + method, + location: range(node), + ownerCallableId, + emitterKey: emitter.key, + emitterBindingId: emitter.bindingId, + eventName, + listenerBindingId: bindingId(listenerArgument, state), + listenerLocation: t.isNode(listenerArgument) + ? range(listenerArgument) + : null, + resolution: + eventName === null + ? "unresolved" + : emitter.complete + ? "complete" + : "partial", + }); +}; + +const eventKind = ( + method: JavaScriptSemanticEventOperation["method"], +): JavaScriptSemanticEventOperation["kind"] => { + if (EVENT_REGISTER_METHODS.some((candidate) => candidate === method)) + return "register"; + if (EVENT_REMOVE_METHODS.some((candidate) => candidate === method)) + return "remove"; + return "dispatch"; +}; + +const emitterIdentity = ( + node: t.Expression | t.Super, + state: JavaScriptSemanticAnalysisState, +): { + readonly key: string; + readonly bindingId: string | null; + readonly complete: boolean; +} => { + if (t.isIdentifier(node)) { + const binding = resolveSemanticBindingState(state, node, node.name); + if (binding !== undefined) + return { + key: `binding:${binding.bindingId}`, + bindingId: binding.bindingId, + complete: true, + }; + return { + key: `global:${node.name}`, + bindingId: null, + complete: !semanticResolutionBlocked(state, node, node.name), + }; + } + if (t.isThisExpression(node)) + return { key: "this", bindingId: null, complete: true }; + return { + key: `expression:${String(node.start ?? -1)}:${String(node.end ?? -1)}`, + bindingId: null, + complete: false, + }; +}; + +const collectTimer = (input: TimerCollectionInput): void => { + const { node, parent, ownerCallableId, state, output } = input; + const method = timerMethod(node.callee, state); + if (method === null) return; + if (output.length >= state.limits.maxTimerOperations) { + reachSemanticLimit(state, "maxTimerOperations"); + return; + } + const kind = TIMER_SCHEDULE_METHODS.some((candidate) => candidate === method) + ? "schedule" + : "cancel"; + output.push({ + node, + method, + kind, + ownerCallableId, + handleBindingId: + kind === "schedule" + ? assignedBindingId(parent, node, state) + : bindingId(node.arguments[0], state), + }); +}; + +const resolveTimers = ( + candidates: readonly TimerCandidate[], +): JavaScriptSemanticTimerOperation[] => { + const schedulesByBinding = new Map(); + for (const candidate of candidates) { + if (candidate.kind !== "schedule" || candidate.handleBindingId === null) + continue; + const existing = schedulesByBinding.get(candidate.handleBindingId) ?? []; + existing.push(candidate); + schedulesByBinding.set(candidate.handleBindingId, existing); + } + return candidates.map((candidate) => { + const linked = + candidate.kind === "cancel" && candidate.handleBindingId !== null + ? (schedulesByBinding.get(candidate.handleBindingId) ?? []) + : []; + return { + timerId: timerId(candidate), + kind: candidate.kind, + method: candidate.method, + location: range(candidate.node), + ownerCallableId: candidate.ownerCallableId, + handleBindingId: candidate.handleBindingId, + linkedTimerId: + linked.length === 1 && linked[0] !== undefined + ? timerId(linked[0]) + : null, + delayMilliseconds: timerDelay(candidate), + resolution: + candidate.kind === "schedule" + ? "complete" + : linked.length === 1 + ? "complete" + : linked.length > 1 + ? "partial" + : "unresolved", + }; + }); +}; + +const timerMethod = ( + callee: t.Node, + state: JavaScriptSemanticAnalysisState, +): JavaScriptSemanticTimerOperation["method"] | null => { + if (t.isIdentifier(callee)) { + const binding = resolveSemanticBindingState(state, callee, callee.name); + if (binding === undefined) { + const method = admittedTimerMethod(callee.name); + if (method === null) return null; + return semanticResolutionBlocked(state, callee, callee.name) + ? null + : method; + } + return timerMethodFromBinding(binding); + } + if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) + return null; + const method = admittedTimerMethod( + semanticStaticPropertyName(callee.property, callee.computed), + ); + return method !== null && + t.isIdentifier(callee.object) && + timerNamespaceBinding( + resolveSemanticBindingState(state, callee.object, callee.object.name), + ) + ? method + : null; +}; + +const admittedTimerMethod = ( + value: string, +): JavaScriptSemanticTimerOperation["method"] | null => + [...TIMER_SCHEDULE_METHODS, ...TIMER_CANCEL_METHODS].find( + (candidate) => candidate === value, + ) ?? null; + +const timerMethodFromBinding = ( + binding: JavaScriptSemanticBindingState, +): JavaScriptSemanticTimerOperation["method"] | null => { + for (const { specifier, importedPath } of binding.directOrigins) { + if (!timerModule(specifier)) continue; + const method = admittedTimerMethod(importedPath.at(-1) ?? ""); + if (method !== null) return method; + } + return null; +}; + +const timerNamespaceBinding = ( + binding: JavaScriptSemanticBindingState | undefined, +): boolean => + binding?.directOrigins.some( + ({ specifier, importedPath }) => + timerModule(specifier) && importedPath.length === 0, + ) ?? false; + +const timerModule = (specifier: string): boolean => + specifier === "timers" || specifier === "node:timers"; + +const assignedBindingId = ( + parent: t.Node | null, + node: t.Node, + state: JavaScriptSemanticAnalysisState, +): string | null => { + const identifier = + t.isVariableDeclarator(parent) && + parent.init === node && + t.isIdentifier(parent.id) + ? parent.id + : t.isAssignmentExpression(parent) && + parent.right === node && + t.isIdentifier(parent.left) + ? parent.left + : null; + return identifier === null + ? null + : (resolveSemanticBindingState(state, identifier, identifier.name) + ?.bindingId ?? null); +}; + +const bindingId = ( + node: + | t.Expression + | t.SpreadElement + | t.JSXNamespacedName + | t.ArgumentPlaceholder + | undefined, + state: JavaScriptSemanticAnalysisState, +): string | null => + t.isIdentifier(node) + ? (resolveSemanticBindingState(state, node, node.name)?.bindingId ?? null) + : null; + +const literalString = ( + node: + | t.Expression + | t.SpreadElement + | t.JSXNamespacedName + | t.ArgumentPlaceholder + | undefined, +): string | null => { + if (t.isStringLiteral(node)) return node.value; + if (t.isTemplateLiteral(node) && node.expressions.length === 0) + return node.quasis[0]?.value.cooked ?? node.quasis[0]?.value.raw ?? null; + return null; +}; + +const timerDelay = (candidate: TimerCandidate): number | null => { + if (candidate.kind !== "schedule" || candidate.method === "setImmediate") + return null; + const delay = candidate.node.arguments[1]; + return t.isNumericLiteral(delay) ? delay.value : null; +}; + +const timerId = (candidate: TimerCandidate): string => + `timer:${candidate.kind}:${String(candidate.node.start ?? -1)}:${String(candidate.node.end ?? -1)}`; + +const memberCallee = ( + node: t.CallExpression | t.OptionalCallExpression, +): t.MemberExpression | t.OptionalMemberExpression | null => + t.isMemberExpression(node.callee) || t.isOptionalMemberExpression(node.callee) + ? node.callee + : null; diff --git a/src/domain/javascriptSemanticChildProcesses.ts b/src/domain/javascriptSemanticChildProcesses.ts new file mode 100644 index 000000000..d8e0cd131 --- /dev/null +++ b/src/domain/javascriptSemanticChildProcesses.ts @@ -0,0 +1,391 @@ +import * as t from "@babel/types"; + +import type { + JavaScriptSemanticCallable, + JavaScriptSemanticChildProcessInteraction, + JavaScriptSemanticChildProcessSpawn, +} from "./javascriptSemanticIr.js"; +import { + reachSemanticLimit, + semanticCallableIdForNode, + semanticStaticPropertyName, +} from "./javascriptSemanticProjection.js"; +import { + resolveSemanticBindingState, + type JavaScriptSemanticAnalysisState, + type JavaScriptSemanticBindingState, +} from "./javascriptSemanticState.js"; +import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; +import { + compareCodePoints, + propertyName, + range, +} from "./javascriptStaticAnalysisHelpers.js"; + +const CHILD_PROCESS_METHODS = ["spawn", "exec", "execFile", "fork"] as const; +const CHILD_LISTENER_METHODS = ["on", "once", "addListener"] as const; + +interface ChildProcessAnalysis { + readonly childProcessSpawns: readonly JavaScriptSemanticChildProcessSpawn[]; + readonly childProcessInteractions: readonly JavaScriptSemanticChildProcessInteraction[]; +} + +interface SpawnCandidate { + readonly node: t.CallExpression | t.OptionalCallExpression; + readonly method: JavaScriptSemanticChildProcessSpawn["method"]; + readonly ownerCallableId: string | null; + readonly resultBindingId: string | null; +} + +interface ChildInteractionContext { + readonly state: JavaScriptSemanticAnalysisState; + readonly spawnByNode: WeakMap; + readonly spawnsByBinding: ReadonlyMap; +} + +/** Recover explicit asynchronous node:child_process ownership candidates. */ +export const collectJavaScriptSemanticChildProcesses = ( + program: t.Program, + state: JavaScriptSemanticAnalysisState, + callables: readonly JavaScriptSemanticCallable[], +): ChildProcessAnalysis => { + const candidates = collectSpawns(program, state, callables); + const childProcessSpawns = candidates.map((candidate) => + immutableSpawn(candidate), + ); + return { + childProcessSpawns, + childProcessInteractions: collectInteractions( + program, + state, + callables, + candidates, + ), + }; +}; + +const collectSpawns = ( + program: t.Program, + state: JavaScriptSemanticAnalysisState, + callables: readonly JavaScriptSemanticCallable[], +): SpawnCandidate[] => { + const output: SpawnCandidate[] = []; + const admittedCallables = new Set( + callables.map(({ callableId }) => callableId), + ); + const callableStack: string[] = []; + traverseJavaScriptAst(program, { + enter: (node, parent) => { + const callableId = semanticCallableIdForNode(node); + if (callableId !== null && admittedCallables.has(callableId)) + callableStack.push(callableId); + if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) + return; + const method = childProcessMethod(node.callee, state); + if (method === null) return; + if (operationLimitReached(output.length, state)) return; + output.push({ + node, + method, + ownerCallableId: callableStack.at(-1) ?? null, + resultBindingId: assignedBindingId(parent, node, state), + }); + }, + exit: (node) => popCallable(node, callableStack), + }); + return output; +}; + +const immutableSpawn = ( + candidate: SpawnCandidate, +): JavaScriptSemanticChildProcessSpawn => { + const options = spawnOptions(candidate); + const command = literalString(candidate.node.arguments[0]); + return { + processId: spawnId(candidate), + method: candidate.method, + location: range(candidate.node), + ownerCallableId: candidate.ownerCallableId, + resultBindingId: candidate.resultBindingId, + command, + argvCount: spawnArgvCount(candidate), + environmentSupplied: objectHasProperty(options, "env"), + stdioMode: stdioMode(options), + resolution: command === null ? "partial" : "complete", + }; +}; + +const collectInteractions = ( + program: t.Program, + state: JavaScriptSemanticAnalysisState, + callables: readonly JavaScriptSemanticCallable[], + spawns: readonly SpawnCandidate[], +): JavaScriptSemanticChildProcessInteraction[] => { + const output: JavaScriptSemanticChildProcessInteraction[] = []; + const callableIds = new Set(callables.map(({ callableId }) => callableId)); + const callableStack: string[] = []; + const spawnByNode = new WeakMap(); + const spawnsByBinding = new Map(); + for (const spawn of spawns) { + spawnByNode.set(spawn.node, spawn); + if (spawn.resultBindingId === null) continue; + const existing = spawnsByBinding.get(spawn.resultBindingId) ?? []; + existing.push(spawn); + spawnsByBinding.set(spawn.resultBindingId, existing); + } + const context: ChildInteractionContext = { + state, + spawnByNode, + spawnsByBinding, + }; + traverseJavaScriptAst(program, { + enter: (node) => { + const callableId = semanticCallableIdForNode(node); + if (callableId !== null && callableIds.has(callableId)) + callableStack.push(callableId); + if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) + return; + const interaction = childInteraction( + node, + callableStack.at(-1) ?? null, + context, + ); + if (interaction === null) return; + if (operationLimitReached(spawns.length + output.length, state)) return; + output.push(interaction); + }, + exit: (node) => popCallable(node, callableStack), + }); + return output; +}; + +const childInteraction = ( + node: t.CallExpression | t.OptionalCallExpression, + ownerCallableId: string | null, + context: ChildInteractionContext, +): JavaScriptSemanticChildProcessInteraction | null => { + const { state, spawnByNode, spawnsByBinding } = context; + const member = memberCallee(node); + if (member === null) return null; + const method = semanticStaticPropertyName(member.property, member.computed); + const binding = objectBinding(member.object, state); + const linked = resolveChildCandidates( + member.object, + binding, + spawnByNode, + spawnsByBinding, + ); + if (linked.length === 0) return null; + const classification = classifyInteraction(method, node.arguments[0]); + if (classification === null) return null; + const { kind, interactionMethod, eventName } = classification; + const listener = node.arguments[1]; + return { + interactionId: `child:${kind}:${String(node.start ?? -1)}:${String(node.end ?? -1)}`, + kind, + method: interactionMethod, + location: range(node), + ownerCallableId, + processBindingId: binding?.bindingId ?? null, + linkedProcessIds: linked + .map((candidate) => spawnId(candidate)) + .sort(compareCodePoints), + eventName: kind === "listener" ? eventName : null, + signalName: + kind === "signal" + ? (literalString(node.arguments[0]) ?? "SIGTERM") + : null, + listenerLocation: + kind === "listener" && t.isNode(listener) ? range(listener) : null, + resolution: linked.length === 1 ? "complete" : "partial", + }; +}; + +const classifyInteraction = ( + method: string, + argument: + | t.Expression + | t.SpreadElement + | t.JSXNamespacedName + | t.ArgumentPlaceholder + | undefined, +): { + readonly kind: JavaScriptSemanticChildProcessInteraction["kind"]; + readonly interactionMethod: JavaScriptSemanticChildProcessInteraction["method"]; + readonly eventName: "exit" | "error" | null; +} | null => { + if (method === "kill") + return { kind: "signal", interactionMethod: "kill", eventName: null }; + const listenerMethod = CHILD_LISTENER_METHODS.find( + (candidate) => candidate === method, + ); + const eventName = literalString(argument); + return listenerMethod !== undefined && + (eventName === "exit" || eventName === "error") + ? { kind: "listener", interactionMethod: listenerMethod, eventName } + : null; +}; + +const childProcessMethod = ( + callee: t.Node, + state: JavaScriptSemanticAnalysisState, +): JavaScriptSemanticChildProcessSpawn["method"] | null => { + if (t.isIdentifier(callee)) { + const binding = resolveSemanticBindingState(state, callee, callee.name); + return binding === undefined ? null : methodFromBinding(binding); + } + if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) + return null; + const method = admittedChildMethod( + semanticStaticPropertyName(callee.property, callee.computed), + ); + if (method === null || !t.isIdentifier(callee.object)) return null; + const binding = resolveSemanticBindingState( + state, + callee.object, + callee.object.name, + ); + return childProcessNamespace(binding) ? method : null; +}; + +const methodFromBinding = ( + binding: JavaScriptSemanticBindingState, +): JavaScriptSemanticChildProcessSpawn["method"] | null => { + for (const { specifier, importedPath } of binding.directOrigins) { + if (!childProcessModule(specifier)) continue; + const method = admittedChildMethod(importedPath.at(-1) ?? ""); + if (method !== null) return method; + } + return null; +}; + +const admittedChildMethod = ( + value: string, +): JavaScriptSemanticChildProcessSpawn["method"] | null => + CHILD_PROCESS_METHODS.find((candidate) => candidate === value) ?? null; + +const childProcessNamespace = ( + binding: JavaScriptSemanticBindingState | undefined, +): boolean => + binding?.directOrigins.some( + ({ specifier, importedPath }) => + childProcessModule(specifier) && importedPath.length === 0, + ) ?? false; + +const childProcessModule = (specifier: string): boolean => + specifier === "child_process" || specifier === "node:child_process"; + +const resolveChildCandidates = ( + node: t.Node, + binding: JavaScriptSemanticBindingState | undefined, + spawnByNode: WeakMap, + spawnsByBinding: ReadonlyMap, +): readonly SpawnCandidate[] => { + const direct = spawnByNode.get(node); + if (direct !== undefined) return [direct]; + return binding === undefined + ? [] + : (spawnsByBinding.get(binding.bindingId) ?? []); +}; + +const spawnOptions = (candidate: SpawnCandidate): t.ObjectExpression | null => { + const index = candidate.method === "exec" ? 1 : 2; + const value = candidate.node.arguments[index]; + return t.isObjectExpression(value) ? value : null; +}; + +const spawnArgvCount = (candidate: SpawnCandidate): number | null => { + if (candidate.method === "exec") return null; + const args = candidate.node.arguments[1]; + return t.isArrayExpression(args) ? args.elements.length : null; +}; + +const objectHasProperty = ( + object: t.ObjectExpression | null, + name: string, +): boolean => objectProperty(object, name) !== null; + +const stdioMode = (options: t.ObjectExpression | null): string => { + const property = objectProperty(options, "stdio"); + if (property === null || !t.isObjectProperty(property)) return "default"; + if (t.isStringLiteral(property.value)) return property.value.value; + if (t.isArrayExpression(property.value)) return "array"; + return "dynamic"; +}; + +const objectProperty = ( + object: t.ObjectExpression | null, + name: string, +): t.ObjectMethod | t.ObjectProperty | null => + object?.properties.find( + (property): property is t.ObjectMethod | t.ObjectProperty => + (t.isObjectMethod(property) || t.isObjectProperty(property)) && + propertyName(property.key) === name, + ) ?? null; + +const objectBinding = ( + node: t.Expression | t.Super, + state: JavaScriptSemanticAnalysisState, +): JavaScriptSemanticBindingState | undefined => + t.isIdentifier(node) + ? resolveSemanticBindingState(state, node, node.name) + : undefined; + +const assignedBindingId = ( + parent: t.Node | null, + node: t.Node, + state: JavaScriptSemanticAnalysisState, +): string | null => { + const identifier = + t.isVariableDeclarator(parent) && + parent.init === node && + t.isIdentifier(parent.id) + ? parent.id + : t.isAssignmentExpression(parent) && + parent.right === node && + t.isIdentifier(parent.left) + ? parent.left + : null; + return identifier === null + ? null + : (resolveSemanticBindingState(state, identifier, identifier.name) + ?.bindingId ?? null); +}; + +const literalString = ( + node: + | t.Expression + | t.SpreadElement + | t.JSXNamespacedName + | t.ArgumentPlaceholder + | undefined, +): string | null => { + if (t.isStringLiteral(node)) return node.value; + if (t.isTemplateLiteral(node) && node.expressions.length === 0) + return node.quasis[0]?.value.cooked ?? node.quasis[0]?.value.raw ?? null; + return null; +}; + +const operationLimitReached = ( + retained: number, + state: JavaScriptSemanticAnalysisState, +): boolean => { + if (retained < state.limits.maxChildProcessOperations) return false; + reachSemanticLimit(state, "maxChildProcessOperations"); + return true; +}; + +const popCallable = (node: t.Node, stack: string[]): void => { + const callableId = semanticCallableIdForNode(node); + if (callableId !== null && stack.at(-1) === callableId) stack.pop(); +}; + +const spawnId = (candidate: SpawnCandidate): string => + `child:spawn:${String(candidate.node.start ?? -1)}:${String(candidate.node.end ?? -1)}`; + +const memberCallee = ( + node: t.CallExpression | t.OptionalCallExpression, +): t.MemberExpression | t.OptionalMemberExpression | null => + t.isMemberExpression(node.callee) || t.isOptionalMemberExpression(node.callee) + ? node.callee + : null; diff --git a/src/domain/javascriptSemanticDataEffectHelpers.ts b/src/domain/javascriptSemanticDataEffectHelpers.ts new file mode 100644 index 000000000..315b6ff9d --- /dev/null +++ b/src/domain/javascriptSemanticDataEffectHelpers.ts @@ -0,0 +1,224 @@ +import * as t from "@babel/types"; + +import { + reachSemanticLimit, + semanticCallableIdForNode, + semanticStaticPropertyName, +} from "./javascriptSemanticProjection.js"; +import { + resolveSemanticBindingState, + semanticResolutionBlocked, + type JavaScriptSemanticAnalysisState, + type JavaScriptSemanticBindingState, +} from "./javascriptSemanticState.js"; +import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; + +/** Shared mutable stacks reset before each deterministic data-effect pass. */ +export interface DataEffectTraversalContext { + readonly state: JavaScriptSemanticAnalysisState; + readonly callableIds: ReadonlySet; + readonly callableStack: string[]; + readonly ancestors: t.Node[]; +} + +/** Resolve a direct or namespace-imported built-in method. */ +export const builtinDataEffectMethod = ( + callee: t.Node, + state: JavaScriptSemanticAnalysisState, + specifiers: readonly string[], +): string | null => { + if (t.isIdentifier(callee)) { + const binding = resolveSemanticBindingState(state, callee, callee.name); + return moduleMethod(binding, specifiers); + } + if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) + return null; + if (!t.isIdentifier(callee.object)) return null; + const binding = resolveSemanticBindingState( + state, + callee.object, + callee.object.name, + ); + const namespace = binding?.directOrigins.some( + ({ specifier, importedPath }) => + specifiers.includes(specifier) && importedPath.length === 0, + ); + return namespace + ? semanticStaticPropertyName(callee.property, callee.computed) + : null; +}; + +const moduleMethod = ( + binding: JavaScriptSemanticBindingState | undefined, + specifiers: readonly string[], +): string | null => { + for (const { specifier, importedPath } of binding?.directOrigins ?? []) { + if (!specifiers.includes(specifier)) continue; + return importedPath.at(-1) ?? null; + } + return null; +}; + +/** Recognize global or node:process aliases without accepting shadowing. */ +export const isSemanticProcessObject = ( + node: t.Expression | t.Super, + state: JavaScriptSemanticAnalysisState, +): boolean => { + if (!t.isIdentifier(node)) return false; + const binding = resolveSemanticBindingState(state, node, node.name); + if (binding === undefined) + return ( + node.name === "process" && + !semanticResolutionBlocked(state, node, node.name) + ); + return binding.directOrigins.some( + ({ specifier }) => specifier === "process" || specifier === "node:process", + ); +}; + +/** Resolve the nearest direct variable/assignment owner before a callable. */ +export const outerDataEffectBinding = ( + node: t.Node, + context: DataEffectTraversalContext, +): string | null => { + for (let index = context.ancestors.length - 1; index >= 0; index -= 1) { + const ancestor = context.ancestors[index]; + if (ancestor === undefined) continue; + if ( + t.isVariableDeclarator(ancestor) && + t.isNode(ancestor.init) && + containsSemanticNode(ancestor.init, node) && + t.isIdentifier(ancestor.id) + ) + return ( + resolveSemanticBindingState( + context.state, + ancestor.id, + ancestor.id.name, + )?.bindingId ?? null + ); + if ( + t.isAssignmentExpression(ancestor) && + containsSemanticNode(ancestor.right, node) && + t.isIdentifier(ancestor.left) + ) + return ( + resolveSemanticBindingState( + context.state, + ancestor.left, + ancestor.left.name, + )?.bindingId ?? null + ); + if (semanticCallableIdForNode(ancestor) !== null) return null; + } + return null; +}; + +/** Traverse with exact callable and ancestor stacks. */ +export const traverseDataEffects = ( + program: t.Program, + context: DataEffectTraversalContext, + visit: (node: t.Node, parent: t.Node | null) => void, +): void => { + context.callableStack.length = 0; + context.ancestors.length = 0; + traverseJavaScriptAst(program, { + enter: (node, parent) => { + const callableId = semanticCallableIdForNode(node); + if (callableId !== null && context.callableIds.has(callableId)) + context.callableStack.push(callableId); + visit(node, parent); + context.ancestors.push(node); + }, + exit: (node) => { + context.ancestors.pop(); + const callableId = semanticCallableIdForNode(node); + if (callableId !== null && context.callableStack.at(-1) === callableId) + context.callableStack.pop(); + }, + }); +}; + +/** Resolve one direct identifier argument to a lexical binding. */ +export const dataEffectArgumentBindingId = ( + node: + | t.Expression + | t.SpreadElement + | t.JSXNamespacedName + | t.ArgumentPlaceholder + | undefined, + state: JavaScriptSemanticAnalysisState, +): string | null => + t.isIdentifier(node) + ? (resolveSemanticBindingState(state, node, node.name)?.bindingId ?? null) + : null; + +/** Read one JSON primitive default without evaluating code. */ +export const dataEffectPrimitive = ( + node: t.Node, +): string | number | boolean | null => { + if (t.isStringLiteral(node) || t.isNumericLiteral(node)) return node.value; + if (t.isBooleanLiteral(node)) return node.value; + return null; +}; + +/** Read one static string or expression-free template. */ +export const dataEffectLiteralString = ( + node: + | t.Expression + | t.SpreadElement + | t.JSXNamespacedName + | t.ArgumentPlaceholder + | undefined, +): string | null => { + if (t.isStringLiteral(node)) return node.value; + if (t.isTemplateLiteral(node) && node.expressions.length === 0) + return node.quasis[0]?.value.cooked ?? node.quasis[0]?.value.raw ?? null; + return null; +}; + +/** Read a member callee from call or construction syntax. */ +export const dataEffectMemberCallee = ( + node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, +): t.MemberExpression | t.OptionalMemberExpression | null => + t.isMemberExpression(node.callee) || t.isOptionalMemberExpression(node.callee) + ? node.callee + : null; + +/** Read a parent member's object when present. */ +export const dataEffectMemberObject = ( + node: t.Node | null, +): t.Expression | t.Super | null => + node !== null && + (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) + ? node.object + : null; + +/** Compare Babel source offsets for exact containment. */ +export const containsSemanticNode = (outer: t.Node, inner: t.Node): boolean => + outer.start !== null && + outer.start !== undefined && + outer.end !== null && + outer.end !== undefined && + inner.start !== null && + inner.start !== undefined && + inner.end !== null && + inner.end !== undefined && + outer.start <= inner.start && + outer.end >= inner.end; + +/** Record one bounded data-effect frontier. */ +export const dataEffectLimitReached = ( + retained: number, + limit: number, + name: + | "maxConfigurationOperations" + | "maxRequestOperations" + | "maxBoundaryOperations" + | "maxResourceOperations", + state: JavaScriptSemanticAnalysisState, +): boolean => { + if (retained < limit) return false; + reachSemanticLimit(state, name); + return true; +}; diff --git a/src/domain/javascriptSemanticDataEffects.ts b/src/domain/javascriptSemanticDataEffects.ts new file mode 100644 index 000000000..64030cef7 --- /dev/null +++ b/src/domain/javascriptSemanticDataEffects.ts @@ -0,0 +1,441 @@ +import * as t from "@babel/types"; + +import type { + JavaScriptSemanticBoundaryOperation, + JavaScriptSemanticCallable, + JavaScriptSemanticConfigurationOperation, + JavaScriptSemanticRequestOperation, +} from "./javascriptSemanticIr.js"; +import { semanticStaticPropertyName } from "./javascriptSemanticProjection.js"; +import { + resolveSemanticBindingState, + semanticResolutionBlocked, + type JavaScriptSemanticAnalysisState, +} from "./javascriptSemanticState.js"; +import { + builtinDataEffectMethod as builtinMethod, + containsSemanticNode as containsNode, + dataEffectArgumentBindingId as argumentBindingId, + dataEffectLimitReached as limitReached, + dataEffectLiteralString as literalString, + dataEffectMemberCallee as memberCallee, + dataEffectMemberObject as memberObject, + dataEffectPrimitive as primitiveValue, + isSemanticProcessObject as isProcessObject, + outerDataEffectBinding as outerAssignedBinding, + traverseDataEffects as traverseWithContext, + type DataEffectTraversalContext as TraversalContext, +} from "./javascriptSemanticDataEffectHelpers.js"; +import { + compareCodePoints, + propertyName, + range, +} from "./javascriptStaticAnalysisHelpers.js"; + +interface DataEffectAnalysis { + readonly configurationOperations: readonly JavaScriptSemanticConfigurationOperation[]; + readonly requestOperations: readonly JavaScriptSemanticRequestOperation[]; + readonly boundaryOperations: readonly JavaScriptSemanticBoundaryOperation[]; +} + +interface ConfigurationCandidate { + readonly node: t.Node; + readonly operation: JavaScriptSemanticConfigurationOperation; +} + +interface RequestCandidate { + readonly node: t.CallExpression | t.OptionalCallExpression | t.NewExpression; + readonly operation: JavaScriptSemanticRequestOperation; +} + +/** Recover configuration, request, and boundary candidates from inert syntax. */ +export const collectJavaScriptSemanticDataEffects = ( + program: t.Program, + state: JavaScriptSemanticAnalysisState, + callables: readonly JavaScriptSemanticCallable[], +): DataEffectAnalysis => { + const context: TraversalContext = { + state, + callableIds: new Set(callables.map(({ callableId }) => callableId)), + callableStack: [], + ancestors: [], + }; + const configuration = collectConfigurations(program, context); + const requests = collectRequests(program, context); + return { + configurationOperations: [ + ...configuration.map(({ operation }) => operation), + ...collectDefaults(program, context, configuration), + ], + requestOperations: [ + ...requests.map(({ operation }) => operation), + ...collectResponseConsumers(program, context, requests), + ], + boundaryOperations: collectBoundaries(program, context), + }; +}; + +const collectConfigurations = ( + program: t.Program, + context: TraversalContext, +): ConfigurationCandidate[] => { + const output: ConfigurationCandidate[] = []; + traverseWithContext(program, context, (node, parent) => { + const details = configurationDetails(node, parent, context); + if (details === null) return; + if ( + limitReached( + output.length, + context.state.limits.maxConfigurationOperations, + "maxConfigurationOperations", + context.state, + ) + ) + return; + const operation: JavaScriptSemanticConfigurationOperation = { + configId: `config:${details.kind}:${String(node.start ?? -1)}:${String(node.end ?? -1)}`, + kind: details.kind, + location: range(node), + ownerCallableId: context.callableStack.at(-1) ?? null, + resultBindingId: outerAssignedBinding(node, context), + key: details.key, + sourceConfigId: null, + value: null, + resolution: details.resolution, + }; + output.push({ node, operation }); + }); + return output; +}; + +const configurationDetails = ( + node: t.Node, + parent: t.Node | null, + context: TraversalContext, +): { + readonly kind: "environment" | "argv" | "file"; + readonly key: string | null; + readonly resolution: "complete" | "partial"; +} | null => { + if ( + (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) && + !(memberObject(parent) === node) + ) { + const processMember = processConfigurationMember(node, context.state); + if (processMember !== null) return processMember; + } + if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) + return null; + const fileMethod = builtinMethod(node.callee, context.state, [ + "fs", + "node:fs", + ]); + return fileMethod === "readFile" || fileMethod === "readFileSync" + ? { + kind: "file", + key: literalString(node.arguments[0]), + resolution: + literalString(node.arguments[0]) === null ? "partial" : "complete", + } + : null; +}; + +const processConfigurationMember = ( + node: t.MemberExpression | t.OptionalMemberExpression, + state: JavaScriptSemanticAnalysisState, +): { + readonly kind: "environment" | "argv"; + readonly key: string | null; + readonly resolution: "complete" | "partial"; +} | null => { + const object = node.object; + if (!t.isMemberExpression(object) && !t.isOptionalMemberExpression(object)) + return null; + if (!isProcessObject(object.object, state)) return null; + const family = semanticStaticPropertyName(object.property, object.computed); + if (family !== "env" && family !== "argv") return null; + const key = semanticStaticPropertyName(node.property, node.computed) || null; + return { + kind: family === "env" ? "environment" : "argv", + key, + resolution: key === null ? "partial" : "complete", + }; +}; + +const collectDefaults = ( + program: t.Program, + context: TraversalContext, + configurations: readonly ConfigurationCandidate[], +): JavaScriptSemanticConfigurationOperation[] => { + const output: JavaScriptSemanticConfigurationOperation[] = []; + traverseWithContext(program, context, (node) => { + if ( + !t.isLogicalExpression(node) || + (node.operator !== "??" && node.operator !== "||") + ) + return; + const sources = configurations.filter(({ node: source }) => + containsNode(node.left, source), + ); + if (sources.length !== 1 || sources[0] === undefined) return; + if ( + limitReached( + configurations.length + output.length, + context.state.limits.maxConfigurationOperations, + "maxConfigurationOperations", + context.state, + ) + ) + return; + output.push({ + configId: `config:default:${String(node.start ?? -1)}:${String(node.end ?? -1)}`, + kind: "default", + location: range(node.right), + ownerCallableId: context.callableStack.at(-1) ?? null, + resultBindingId: outerAssignedBinding(node, context), + key: sources[0].operation.key, + sourceConfigId: sources[0].operation.configId, + value: primitiveValue(node.right), + resolution: primitiveValue(node.right) === null ? "partial" : "complete", + }); + }); + return output; +}; + +const collectRequests = ( + program: t.Program, + context: TraversalContext, +): RequestCandidate[] => { + const output: RequestCandidate[] = []; + traverseWithContext(program, context, (node) => { + if ( + !t.isCallExpression(node) && + !t.isOptionalCallExpression(node) && + !t.isNewExpression(node) + ) + return; + const method = requestMethod(node, context.state); + if (method === null) return; + if ( + limitReached( + output.length, + context.state.limits.maxRequestOperations, + "maxRequestOperations", + context.state, + ) + ) + return; + const endpoint = requestEndpoint(node); + output.push({ + node, + operation: { + requestId: `request:${String(node.start ?? -1)}:${String(node.end ?? -1)}`, + kind: "request", + method, + location: range(node), + ownerCallableId: context.callableStack.at(-1) ?? null, + resultBindingId: outerAssignedBinding(node, context), + linkedRequestIds: [], + endpoint, + fields: requestFields(node, method, context.state), + resolution: endpoint === null ? "partial" : "complete", + }, + }); + }); + return output; +}; + +const collectResponseConsumers = ( + program: t.Program, + context: TraversalContext, + requests: readonly RequestCandidate[], +): JavaScriptSemanticRequestOperation[] => { + const requestsByBinding = new Map(); + for (const request of requests) { + const bindingId = request.operation.resultBindingId; + if (bindingId === null) continue; + const existing = requestsByBinding.get(bindingId) ?? []; + existing.push(request); + requestsByBinding.set(bindingId, existing); + } + const output: JavaScriptSemanticRequestOperation[] = []; + traverseWithContext(program, context, (node) => { + if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return; + const member = memberCallee(node); + if (member === null || !t.isIdentifier(member.object)) return; + const method = semanticStaticPropertyName(member.property, member.computed); + if (method !== "json" && method !== "text" && method !== "arrayBuffer") + return; + const binding = resolveSemanticBindingState( + context.state, + member.object, + member.object.name, + ); + const linked = + binding === undefined + ? [] + : (requestsByBinding.get(binding.bindingId) ?? []); + if (linked.length === 0) return; + if ( + limitReached( + requests.length + output.length, + context.state.limits.maxRequestOperations, + "maxRequestOperations", + context.state, + ) + ) + return; + output.push({ + requestId: `response:${String(node.start ?? -1)}:${String(node.end ?? -1)}`, + kind: "response-consumer", + method, + location: range(node), + ownerCallableId: context.callableStack.at(-1) ?? null, + resultBindingId: outerAssignedBinding(node, context), + linkedRequestIds: linked + .map(({ operation }) => operation.requestId) + .sort(compareCodePoints), + endpoint: null, + fields: [], + resolution: linked.length === 1 ? "complete" : "partial", + }); + }); + return output; +}; + +const collectBoundaries = ( + program: t.Program, + context: TraversalContext, +): JavaScriptSemanticBoundaryOperation[] => { + const output: JavaScriptSemanticBoundaryOperation[] = []; + traverseWithContext(program, context, (node) => { + if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return; + const details = boundaryDetails(node, context.state); + if (details === null) return; + if ( + limitReached( + output.length, + context.state.limits.maxBoundaryOperations, + "maxBoundaryOperations", + context.state, + ) + ) + return; + output.push({ + boundaryId: `boundary:${details.kind}:${String(node.start ?? -1)}:${String(node.end ?? -1)}`, + kind: details.kind, + method: details.method, + location: range(node), + ownerCallableId: context.callableStack.at(-1) ?? null, + sourceBindingId: argumentBindingId(node.arguments[0], context.state), + resultBindingId: outerAssignedBinding(node, context), + resolution: details.resolution, + }); + }); + return output; +}; + +const boundaryDetails = ( + node: t.CallExpression | t.OptionalCallExpression, + state: JavaScriptSemanticAnalysisState, +): { + readonly kind: JavaScriptSemanticBoundaryOperation["kind"]; + readonly method: string; + readonly resolution: "complete" | "partial"; +} | null => { + if (t.isIdentifier(node.callee)) { + const name = node.callee.name; + const unbound = + resolveSemanticBindingState(state, node.callee, name) === undefined && + !semanticResolutionBlocked(state, node.callee, name); + if ( + unbound && + ["String", "Number", "Boolean", "parseInt", "parseFloat"].includes(name) + ) + return { kind: "coerce", method: name, resolution: "complete" }; + } + const member = memberCallee(node); + if (member === null) return null; + const method = semanticStaticPropertyName(member.property, member.computed); + if ( + method === "parse" && + t.isIdentifier(member.object, { name: "JSON" }) && + resolveSemanticBindingState(state, member.object, "JSON") === undefined && + !semanticResolutionBlocked(state, member.object, "JSON") + ) + return { kind: "parse", method: "JSON.parse", resolution: "complete" }; + if (method === "parse" || method === "safeParse") + return { kind: "parse", method, resolution: "partial" }; + if (method === "validate" || method === "assert" || method === "is") + return { kind: "validate", method, resolution: "partial" }; + return null; +}; + +const requestMethod = ( + node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, + state: JavaScriptSemanticAnalysisState, +): JavaScriptSemanticRequestOperation["method"] | null => { + if (t.isIdentifier(node.callee)) { + const name = node.callee.name; + const unbound = + resolveSemanticBindingState(state, node.callee, name) === undefined && + !semanticResolutionBlocked(state, node.callee, name); + if (!t.isNewExpression(node) && unbound && name === "fetch") return "fetch"; + if (t.isNewExpression(node) && unbound && name === "WebSocket") + return "WebSocket"; + } + const member = memberCallee(node); + if (member === null || !t.isIdentifier(member.object)) return null; + const method = semanticStaticPropertyName(member.property, member.computed); + if (method !== "request" && method !== "get") return null; + const binding = resolveSemanticBindingState( + state, + member.object, + member.object.name, + ); + return binding?.directOrigins.some( + ({ specifier, importedPath }) => + ["http", "https", "node:http", "node:https"].includes(specifier) && + importedPath.length === 0, + ) + ? method + : null; +}; + +const requestFields = ( + node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, + method: JavaScriptSemanticRequestOperation["method"], + state: JavaScriptSemanticAnalysisState, +): JavaScriptSemanticRequestOperation["fields"] => { + const candidate = + method === "fetch" || method === "WebSocket" + ? node.arguments[1] + : t.isObjectExpression(node.arguments[0]) + ? node.arguments[0] + : node.arguments[1]; + if (!t.isObjectExpression(candidate)) return []; + return candidate.properties.flatMap((property) => { + if (!t.isObjectProperty(property)) return []; + const name = propertyName(property.key); + if (name.length === 0) return []; + return [ + { + name, + sourceBindingId: t.isIdentifier(property.value) + ? (resolveSemanticBindingState( + state, + property.value, + property.value.name, + )?.bindingId ?? null) + : null, + }, + ]; + }); +}; + +const requestEndpoint = ( + node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, +): string | null => + t.isObjectExpression(node.arguments[0]) + ? null + : literalString(node.arguments[0]); diff --git a/src/domain/javascriptSemanticDerivedAnalysis.ts b/src/domain/javascriptSemanticDerivedAnalysis.ts new file mode 100644 index 000000000..d3e630ac9 --- /dev/null +++ b/src/domain/javascriptSemanticDerivedAnalysis.ts @@ -0,0 +1,99 @@ +import type * as t from "@babel/types"; + +import { collectJavaScriptSemanticAsyncEffects } from "./javascriptSemanticAsyncEffects.js"; +import { collectJavaScriptSemanticCalls } from "./javascriptSemanticCalls.js"; +import { collectJavaScriptSemanticChildProcesses } from "./javascriptSemanticChildProcesses.js"; +import { collectJavaScriptSemanticFingerprints } from "./javascriptSemanticFingerprints.js"; +import { collectJavaScriptSemanticDataEffects } from "./javascriptSemanticDataEffects.js"; +import type { + JavaScriptSemanticCallable, + JavaScriptSemanticIr, +} from "./javascriptSemanticIr.js"; +import { collectJavaScriptSemanticPromises } from "./javascriptSemanticPromises.js"; +import { collectJavaScriptSemanticObjects } from "./javascriptSemanticObjects.js"; +import { collectJavaScriptSemanticResources } from "./javascriptSemanticResources.js"; +import type { JavaScriptSemanticAnalysisState } from "./javascriptSemanticState.js"; + +type DerivedSemanticAnalysis = Pick< + JavaScriptSemanticIr, + | "argumentFlows" + | "callResultFlows" + | "callReturnFlows" + | "callSites" + | "boundaryOperations" + | "childProcessInteractions" + | "childProcessSpawns" + | "closureCaptures" + | "configurationOperations" + | "eventOperations" + | "frontiers" + | "functionFingerprints" + | "objectOperations" + | "promiseOperations" + | "requestOperations" + | "resourceOperations" + | "timerOperations" +>; + +/** Compose bounded semantic passes after lexical definitions are available. */ +export const collectJavaScriptDerivedSemantics = ( + program: t.Program, + state: JavaScriptSemanticAnalysisState, + callables: readonly JavaScriptSemanticCallable[], + parserPartial: boolean, +): DerivedSemanticAnalysis => { + const calls = collectJavaScriptSemanticCalls(program, state, callables); + const promiseOperations = collectJavaScriptSemanticPromises( + program, + state, + callables, + ); + const asyncEffects = collectJavaScriptSemanticAsyncEffects( + program, + state, + callables, + ); + const childProcesses = collectJavaScriptSemanticChildProcesses( + program, + state, + callables, + ); + const dataEffects = collectJavaScriptSemanticDataEffects( + program, + state, + callables, + ); + const resourceOperations = collectJavaScriptSemanticResources( + program, + state, + callables, + ); + const objectOperations = collectJavaScriptSemanticObjects( + program, + state, + callables, + ); + const functionFingerprints = collectJavaScriptSemanticFingerprints({ + state, + callables, + calls, + promises: promiseOperations, + events: asyncEffects.eventOperations, + timers: asyncEffects.timerOperations, + childProcessSpawns: childProcesses.childProcessSpawns, + childProcessInteractions: childProcesses.childProcessInteractions, + requestOperations: dataEffects.requestOperations, + resourceOperations, + parserPartial, + }); + return { + ...calls, + promiseOperations, + ...asyncEffects, + ...childProcesses, + ...dataEffects, + resourceOperations, + objectOperations, + functionFingerprints, + }; +}; diff --git a/src/domain/javascriptSemanticFingerprints.ts b/src/domain/javascriptSemanticFingerprints.ts new file mode 100644 index 000000000..b96190fa4 --- /dev/null +++ b/src/domain/javascriptSemanticFingerprints.ts @@ -0,0 +1,262 @@ +import { createHash } from "node:crypto"; + +import * as t from "@babel/types"; + +import type { JavaScriptSemanticCallAnalysis } from "./javascriptSemanticCalls.js"; +import type { + JavaScriptSemanticCallable, + JavaScriptSemanticChildProcessInteraction, + JavaScriptSemanticChildProcessSpawn, + JavaScriptSemanticEventOperation, + JavaScriptSemanticFunctionFingerprint, + JavaScriptSemanticPromiseOperation, + JavaScriptSemanticRequestOperation, + JavaScriptSemanticResourceOperation, + JavaScriptSemanticTimerOperation, +} from "./javascriptSemanticIr.js"; +import { semanticCallableIdForNode } from "./javascriptSemanticProjection.js"; +import type { JavaScriptSemanticAnalysisState } from "./javascriptSemanticState.js"; +import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; +import { compareCodePoints } from "./javascriptStaticAnalysisHelpers.js"; + +interface SemanticFingerprintCollectionInput { + readonly state: JavaScriptSemanticAnalysisState; + readonly callables: readonly JavaScriptSemanticCallable[]; + readonly calls: JavaScriptSemanticCallAnalysis; + readonly promises: readonly JavaScriptSemanticPromiseOperation[]; + readonly events: readonly JavaScriptSemanticEventOperation[]; + readonly timers: readonly JavaScriptSemanticTimerOperation[]; + readonly childProcessSpawns: readonly JavaScriptSemanticChildProcessSpawn[]; + readonly childProcessInteractions: readonly JavaScriptSemanticChildProcessInteraction[]; + readonly requestOperations: readonly JavaScriptSemanticRequestOperation[]; + readonly resourceOperations: readonly JavaScriptSemanticResourceOperation[]; + readonly parserPartial: boolean; +} + +/** Build bounded rename- and formatting-resistant components per callable. */ +export const collectJavaScriptSemanticFingerprints = ( + input: SemanticFingerprintCollectionInput, +): JavaScriptSemanticFunctionFingerprint[] => { + const { + state, + callables, + calls, + promises, + events, + timers, + childProcessSpawns, + childProcessInteractions, + requestOperations, + resourceOperations, + parserPartial, + } = input; + return callables.flatMap((callable) => { + if (callable.kind === "class") return []; + const node = state.callableNodesById.get(callable.callableId); + if (node === undefined) return []; + const syntax = fingerprintSyntax(node, callable.callableId); + const promiseEffects = promises.filter( + ({ ownerCallableId }) => ownerCallableId === callable.callableId, + ); + const eventEffects = events.filter( + ({ ownerCallableId }) => ownerCallableId === callable.callableId, + ); + const timerEffects = timers.filter( + ({ ownerCallableId }) => ownerCallableId === callable.callableId, + ); + const childProcessEffects = [ + ...childProcessSpawns, + ...childProcessInteractions, + ].filter(({ ownerCallableId }) => ownerCallableId === callable.callableId); + const networkEffects = requestOperations.filter( + ({ ownerCallableId }) => ownerCallableId === callable.callableId, + ); + const resourceEffects = resourceOperations.filter( + ({ ownerCallableId }) => ownerCallableId === callable.callableId, + ); + return [ + { + callableId: callable.callableId, + status: + parserPartial || + state.limitsReached.size > 0 || + callable.returnCoverage.status !== "complete" + ? "partial" + : "complete", + components: { + parameterArity: callableParameterArity(node), + normalizedAstSha256: digest(syntax.normalizedTokens), + controlFlowSha256: digest(syntax.controlFlowTokens), + relationShapeSha256: digest( + relationShape(callable, calls, promiseEffects), + ), + literalSetSha256: digest( + [...new Set(syntax.literals)].sort(compareCodePoints), + ), + effects: [ + ...(isAsyncCallable(node) ? (["async"] as const) : []), + ...(childProcessEffects.length > 0 + ? (["child-process"] as const) + : []), + ...(eventEffects.length > 0 ? (["event"] as const) : []), + ...(networkEffects.length > 0 ? (["network"] as const) : []), + ...(promiseEffects.length > 0 ? (["promise"] as const) : []), + ...(resourceEffects.length > 0 ? (["resource"] as const) : []), + ...(timerEffects.length > 0 ? (["timer"] as const) : []), + ], + }, + limitations: [ + "The fingerprint is a bounded static candidate and does not prove behavioral equivalence.", + ...(parserPartial || state.limitsReached.size > 0 + ? ["Incomplete semantic recovery makes this fingerprint partial."] + : []), + ], + }, + ]; + }); +}; + +interface FingerprintSyntax { + readonly normalizedTokens: readonly string[]; + readonly controlFlowTokens: readonly string[]; + readonly literals: readonly string[]; +} + +const fingerprintSyntax = ( + root: t.Node, + targetCallableId: string, +): FingerprintSyntax => { + const normalizedTokens: string[] = []; + const controlFlowTokens: string[] = []; + const literals: string[] = []; + const callableStack: string[] = []; + traverseJavaScriptAst(root, { + enter: (node, parent) => { + const callableId = semanticCallableIdForNode(node); + if (callableId !== null) callableStack.push(callableId); + if (callableStack.at(-1) !== targetCallableId) return; + normalizedTokens.push(normalizedNodeToken(node, parent)); + const control = controlFlowToken(node); + if (control !== null) controlFlowTokens.push(control); + const literal = semanticLiteral(node); + if (literal !== null) literals.push(literal); + }, + exit: (node) => { + const callableId = semanticCallableIdForNode(node); + if (callableId !== null && callableStack.at(-1) === callableId) + callableStack.pop(); + }, + }); + return { normalizedTokens, controlFlowTokens, literals }; +}; + +const normalizedNodeToken = (node: t.Node, parent: t.Node | null): string => { + if (t.isIdentifier(node)) + return propertyIdentity(node, parent) + ? `property:${node.name}` + : "identifier"; + if (t.isStringLiteral(node)) + return propertyIdentity(node, parent) + ? `property:${node.value}` + : "string-literal"; + const attributes = [ + primitiveAttribute(node, "operator"), + primitiveAttribute(node, "kind"), + primitiveAttribute(node, "computed"), + primitiveAttribute(node, "optional"), + primitiveAttribute(node, "async"), + primitiveAttribute(node, "generator"), + ].filter((value): value is string => value !== null); + return [node.type, ...attributes].join(":"); +}; + +const primitiveAttribute = (node: t.Node, key: string): string | null => { + const value: unknown = Reflect.get(node, key); + return typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ? `${key}=${String(value)}` + : null; +}; + +const propertyIdentity = (node: t.Node, parent: t.Node | null): boolean => { + if (parent === null) return false; + if ( + (t.isMemberExpression(parent) || t.isOptionalMemberExpression(parent)) && + parent.property === node + ) + return !parent.computed || t.isStringLiteral(node); + if ( + (t.isObjectProperty(parent) || + t.isObjectMethod(parent) || + t.isClassMethod(parent) || + t.isClassProperty(parent)) && + parent.key === node + ) + return !parent.computed || t.isStringLiteral(node); + return false; +}; + +const controlFlowToken = (node: t.Node): string | null => { + if ( + !t.isIfStatement(node) && + !t.isConditionalExpression(node) && + !t.isSwitchStatement(node) && + !t.isSwitchCase(node) && + !t.isLoop(node) && + !t.isTryStatement(node) && + !t.isCatchClause(node) && + !t.isReturnStatement(node) && + !t.isThrowStatement(node) && + !t.isAwaitExpression(node) && + !t.isYieldExpression(node) && + !t.isLogicalExpression(node) + ) + return null; + const operator = primitiveAttribute(node, "operator"); + return operator === null ? node.type : `${node.type}:${operator}`; +}; + +const semanticLiteral = (node: t.Node): string | null => { + if (t.isStringLiteral(node)) return `string:${node.value}`; + if (t.isNumericLiteral(node)) return `number:${String(node.value)}`; + if (t.isBooleanLiteral(node)) return `boolean:${String(node.value)}`; + if (t.isNullLiteral(node)) return "null"; + if (t.isBigIntLiteral(node)) return `bigint:${node.value}`; + if (t.isRegExpLiteral(node)) return `regexp:${node.pattern}/${node.flags}`; + if (t.isTemplateElement(node)) + return `template:${node.value.cooked ?? node.value.raw}`; + return null; +}; + +const relationShape = ( + callable: JavaScriptSemanticCallable, + calls: JavaScriptSemanticCallAnalysis, + promises: readonly JavaScriptSemanticPromiseOperation[], +): string[] => [ + `returns:${String(callable.returnSites.length)}:${callable.returnCoverage.status}`, + ...calls.callSites + .filter(({ callerCallableId }) => callerCallableId === callable.callableId) + .map( + ({ kind, resolution, arguments: args, calleeCallableIds }) => + `call:${kind}:${resolution}:${String(args.length)}:${String(calleeCallableIds.length)}`, + ), + `captures:${String( + calls.closureCaptures.filter( + ({ callableId }) => callableId === callable.callableId, + ).length, + )}`, + ...promises.map( + ({ kind, method, ownership, sourceResolution }) => + `promise:${kind}:${method}:${ownership}:${sourceResolution}`, + ), +]; + +const callableParameterArity = (node: t.Node): number => + t.isFunction(node) ? node.params.length : 0; + +const isAsyncCallable = (node: t.Node): boolean => + t.isFunction(node) && node.async; + +const digest = (value: readonly string[]): string => + createHash("sha256").update(JSON.stringify(value)).digest("hex"); diff --git a/src/domain/javascriptSemanticIr.ts b/src/domain/javascriptSemanticIr.ts index d098b51b8..82e92504d 100644 --- a/src/domain/javascriptSemanticIr.ts +++ b/src/domain/javascriptSemanticIr.ts @@ -13,6 +13,15 @@ export interface JavaScriptSemanticLimits { readonly maxArgumentFlows: number; readonly maxCallReturnFlows: number; readonly maxClosureCaptures: number; + readonly maxPromiseOperations: number; + readonly maxEventOperations: number; + readonly maxTimerOperations: number; + readonly maxChildProcessOperations: number; + readonly maxConfigurationOperations: number; + readonly maxRequestOperations: number; + readonly maxBoundaryOperations: number; + readonly maxResourceOperations: number; + readonly maxObjectOperations: number; readonly maxFrontiers: number; readonly maxValueDepth: number; readonly maxUnionValues: number; @@ -32,6 +41,15 @@ export const DEFAULT_JAVASCRIPT_SEMANTIC_LIMITS: JavaScriptSemanticLimits = { maxArgumentFlows: 100_000, maxCallReturnFlows: 100_000, maxClosureCaptures: 100_000, + maxPromiseOperations: 100_000, + maxEventOperations: 100_000, + maxTimerOperations: 100_000, + maxChildProcessOperations: 100_000, + maxConfigurationOperations: 100_000, + maxRequestOperations: 100_000, + maxBoundaryOperations: 100_000, + maxResourceOperations: 100_000, + maxObjectOperations: 100_000, maxFrontiers: 20_000, maxValueDepth: 16, maxUnionValues: 32, @@ -208,6 +226,218 @@ export interface JavaScriptSemanticClosureCapture { readonly referenceLocation: JavaScriptSourceRange; } +/** One bounded promise/task operation recovered from explicit syntax. */ +export interface JavaScriptSemanticPromiseOperation { + readonly promiseId: string; + readonly kind: + | "constructor" + | "static" + | "chain" + | "aggregate" + | "awaited-expression"; + readonly method: + | "new" + | "resolve" + | "reject" + | "then" + | "catch" + | "finally" + | "all" + | "allSettled" + | "await"; + readonly location: JavaScriptSourceRange; + readonly ownerCallableId: string | null; + readonly ownership: + | "awaited" + | "returned" + | "detached" + | "assigned" + | "chained" + | "aggregated" + | "unknown"; + readonly ownerBindingId: string | null; + readonly returnSiteId: string | null; + readonly sourcePromiseIds: readonly string[]; + readonly sourceResolution: "complete" | "partial" | "unresolved"; +} + +/** Rename-resistant bounded component commitment for one callable. */ +export interface JavaScriptSemanticFunctionFingerprint { + readonly callableId: string; + readonly status: "complete" | "partial" | "unavailable"; + readonly components: { + readonly parameterArity: number; + readonly normalizedAstSha256: string; + readonly controlFlowSha256: string; + readonly relationShapeSha256: string; + readonly literalSetSha256: string; + readonly effects: readonly ( + | "async" + | "child-process" + | "event" + | "network" + | "promise" + | "resource" + | "timer" + )[]; + }; + readonly limitations: readonly string[]; +} + +/** One EventEmitter-style registration, removal, or dispatch candidate. */ +export interface JavaScriptSemanticEventOperation { + readonly eventId: string; + readonly kind: "register" | "remove" | "dispatch"; + readonly method: + | "on" + | "once" + | "addListener" + | "prependListener" + | "prependOnceListener" + | "off" + | "removeListener" + | "removeAllListeners" + | "emit"; + readonly location: JavaScriptSourceRange; + readonly ownerCallableId: string | null; + readonly emitterKey: string; + readonly emitterBindingId: string | null; + readonly eventName: string | null; + readonly listenerBindingId: string | null; + readonly listenerLocation: JavaScriptSourceRange | null; + readonly resolution: "complete" | "partial" | "unresolved"; +} + +/** One bounded timer scheduling or cancellation candidate. */ +export interface JavaScriptSemanticTimerOperation { + readonly timerId: string; + readonly kind: "schedule" | "cancel"; + readonly method: + | "setTimeout" + | "setInterval" + | "setImmediate" + | "clearTimeout" + | "clearInterval" + | "clearImmediate"; + readonly location: JavaScriptSourceRange; + readonly ownerCallableId: string | null; + readonly handleBindingId: string | null; + readonly linkedTimerId: string | null; + readonly delayMilliseconds: number | null; + readonly resolution: "complete" | "partial" | "unresolved"; +} + +/** One explicit asynchronous node:child_process creation candidate. */ +export interface JavaScriptSemanticChildProcessSpawn { + readonly processId: string; + readonly method: "spawn" | "exec" | "execFile" | "fork"; + readonly location: JavaScriptSourceRange; + readonly ownerCallableId: string | null; + readonly resultBindingId: string | null; + readonly command: string | null; + readonly argvCount: number | null; + readonly environmentSupplied: boolean; + readonly stdioMode: string; + readonly resolution: "complete" | "partial"; +} + +/** One exit/error listener or signal operation linked to child candidates. */ +export interface JavaScriptSemanticChildProcessInteraction { + readonly interactionId: string; + readonly kind: "listener" | "signal"; + readonly method: "on" | "once" | "addListener" | "kill"; + readonly location: JavaScriptSourceRange; + readonly ownerCallableId: string | null; + readonly processBindingId: string | null; + readonly linkedProcessIds: readonly string[]; + readonly eventName: "exit" | "error" | null; + readonly signalName: string | null; + readonly listenerLocation: JavaScriptSourceRange | null; + readonly resolution: "complete" | "partial"; +} + +/** One environment, argv, file, or default configuration source. */ +export interface JavaScriptSemanticConfigurationOperation { + readonly configId: string; + readonly kind: "environment" | "argv" | "file" | "default"; + readonly location: JavaScriptSourceRange; + readonly ownerCallableId: string | null; + readonly resultBindingId: string | null; + readonly key: string | null; + readonly sourceConfigId: string | null; + readonly value: string | number | boolean | null; + readonly resolution: "complete" | "partial"; +} + +/** One request construction or response-consumer candidate. */ +export interface JavaScriptSemanticRequestOperation { + readonly requestId: string; + readonly kind: "request" | "response-consumer"; + readonly method: + | "fetch" + | "WebSocket" + | "request" + | "get" + | "json" + | "text" + | "arrayBuffer"; + readonly location: JavaScriptSourceRange; + readonly ownerCallableId: string | null; + readonly resultBindingId: string | null; + readonly linkedRequestIds: readonly string[]; + readonly endpoint: string | null; + readonly fields: readonly { + readonly name: string; + readonly sourceBindingId: string | null; + }[]; + readonly resolution: "complete" | "partial" | "unresolved"; +} + +/** One parse, coercion, or validation boundary candidate. */ +export interface JavaScriptSemanticBoundaryOperation { + readonly boundaryId: string; + readonly kind: "parse" | "coerce" | "validate"; + readonly method: string; + readonly location: JavaScriptSourceRange; + readonly ownerCallableId: string | null; + readonly sourceBindingId: string | null; + readonly resultBindingId: string | null; + readonly resolution: "complete" | "partial"; +} + +/** One explicit built-in resource acquisition or release candidate. */ +export interface JavaScriptSemanticResourceOperation { + readonly resourceId: string; + readonly kind: "acquire" | "release"; + readonly method: + | "open" + | "openSync" + | "createReadStream" + | "createWriteStream" + | "connect" + | "createConnection" + | "close" + | "destroy" + | "end"; + readonly location: JavaScriptSourceRange; + readonly ownerCallableId: string | null; + readonly resultBindingId: string | null; + readonly linkedResourceIds: readonly string[]; + readonly resolution: "complete" | "partial"; +} + +/** One static property read/write, spread, or destructuring candidate. */ +export interface JavaScriptSemanticObjectOperation { + readonly objectOperationId: string; + readonly kind: "read" | "write" | "spread" | "destructure"; + readonly location: JavaScriptSourceRange; + readonly ownerCallableId: string | null; + readonly objectBindingId: string | null; + readonly targetBindingId: string | null; + readonly propertyName: string | null; + readonly resolution: "complete" | "partial"; +} + /** One syntax location where exact local semantic continuation is unavailable. */ export interface JavaScriptSemanticFrontier { readonly kind: "dynamic-call" | "dynamic-property"; @@ -247,10 +477,10 @@ interface JavaScriptSemanticCoverage { readonly limitsReached: readonly (keyof JavaScriptSemanticLimits)[]; } -/** Provider-neutral JavaScript semantic IR v3. */ +/** Provider-neutral JavaScript semantic IR v4. */ export interface JavaScriptSemanticIr { readonly schema: "JavaScriptSemanticIR"; - readonly schemaVersion: 3; + readonly schemaVersion: 4; readonly scopes: readonly JavaScriptSemanticScope[]; readonly bindings: readonly JavaScriptSemanticBinding[]; readonly callables: readonly JavaScriptSemanticCallable[]; @@ -261,6 +491,17 @@ export interface JavaScriptSemanticIr { readonly callReturnFlows: readonly JavaScriptSemanticCallReturnFlow[]; readonly callResultFlows: readonly JavaScriptSemanticCallResultFlow[]; readonly closureCaptures: readonly JavaScriptSemanticClosureCapture[]; + readonly promiseOperations: readonly JavaScriptSemanticPromiseOperation[]; + readonly eventOperations: readonly JavaScriptSemanticEventOperation[]; + readonly timerOperations: readonly JavaScriptSemanticTimerOperation[]; + readonly childProcessSpawns: readonly JavaScriptSemanticChildProcessSpawn[]; + readonly childProcessInteractions: readonly JavaScriptSemanticChildProcessInteraction[]; + readonly configurationOperations: readonly JavaScriptSemanticConfigurationOperation[]; + readonly requestOperations: readonly JavaScriptSemanticRequestOperation[]; + readonly boundaryOperations: readonly JavaScriptSemanticBoundaryOperation[]; + readonly resourceOperations: readonly JavaScriptSemanticResourceOperation[]; + readonly objectOperations: readonly JavaScriptSemanticObjectOperation[]; + readonly functionFingerprints: readonly JavaScriptSemanticFunctionFingerprint[]; readonly frontiers: readonly JavaScriptSemanticFrontier[]; readonly coverage: JavaScriptSemanticCoverage; readonly limitations: readonly string[]; @@ -287,7 +528,7 @@ export const semanticBinding = ( /** Fail-closed result when Babel cannot produce an inert syntax tree. */ export const failedJavaScriptSemanticIr = (): JavaScriptSemanticIr => ({ schema: "JavaScriptSemanticIR", - schemaVersion: 3, + schemaVersion: 4, scopes: [], bindings: [], callables: [], @@ -298,6 +539,17 @@ export const failedJavaScriptSemanticIr = (): JavaScriptSemanticIr => ({ callReturnFlows: [], callResultFlows: [], closureCaptures: [], + promiseOperations: [], + eventOperations: [], + timerOperations: [], + childProcessSpawns: [], + childProcessInteractions: [], + configurationOperations: [], + requestOperations: [], + boundaryOperations: [], + resourceOperations: [], + objectOperations: [], + functionFingerprints: [], frontiers: [], coverage: { status: "failed", omittedCount: null, limitsReached: [] }, limitations: [ diff --git a/src/domain/javascriptSemanticObjects.ts b/src/domain/javascriptSemanticObjects.ts new file mode 100644 index 000000000..2058ea976 --- /dev/null +++ b/src/domain/javascriptSemanticObjects.ts @@ -0,0 +1,181 @@ +import * as t from "@babel/types"; + +import type { + JavaScriptSemanticCallable, + JavaScriptSemanticObjectOperation, +} from "./javascriptSemanticIr.js"; +import { + reachSemanticLimit, + semanticCallableIdForNode, + semanticStaticPropertyName, +} from "./javascriptSemanticProjection.js"; +import { + resolveSemanticBindingState, + type JavaScriptSemanticAnalysisState, +} from "./javascriptSemanticState.js"; +import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; +import { propertyName, range } from "./javascriptStaticAnalysisHelpers.js"; + +/** Recover bounded static property reads, writes, spreads, and destructuring. */ +export const collectJavaScriptSemanticObjects = ( + program: t.Program, + state: JavaScriptSemanticAnalysisState, + callables: readonly JavaScriptSemanticCallable[], +): JavaScriptSemanticObjectOperation[] => { + const output: JavaScriptSemanticObjectOperation[] = []; + const callableIds = new Set(callables.map(({ callableId }) => callableId)); + const callableStack: string[] = []; + const context: ObjectCollectionContext = { state, output }; + traverseJavaScriptAst(program, { + enter: (node, parent) => { + const callableId = semanticCallableIdForNode(node); + if (callableId !== null && callableIds.has(callableId)) + callableStack.push(callableId); + const ownerCallableId = callableStack.at(-1) ?? null; + if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) + collectMember(node, parent, ownerCallableId, context); + else if ( + t.isSpreadElement(node) && + parent !== null && + t.isObjectExpression(parent) + ) + addObjectOperation( + { + node, + kind: "spread", + ownerCallableId, + objectBindingId: expressionBindingId(node.argument, state), + targetBindingId: null, + propertyName: null, + }, + state, + output, + ); + else if (t.isVariableDeclarator(node) && t.isObjectPattern(node.id)) + collectDestructuring(node, ownerCallableId, state, output); + }, + exit: (node) => { + const callableId = semanticCallableIdForNode(node); + if (callableId !== null && callableStack.at(-1) === callableId) + callableStack.pop(); + }, + }); + return output; +}; + +interface ObjectCollectionContext { + readonly state: JavaScriptSemanticAnalysisState; + readonly output: JavaScriptSemanticObjectOperation[]; +} + +const collectMember = ( + node: t.MemberExpression | t.OptionalMemberExpression, + parent: t.Node | null, + ownerCallableId: string | null, + context: ObjectCollectionContext, +): void => { + const { state, output } = context; + const name = semanticStaticPropertyName(node.property, node.computed); + if (name.length === 0) return; + const write = + (t.isAssignmentExpression(parent) && parent.left === node) || + (t.isUpdateExpression(parent) && parent.argument === node); + addObjectOperation( + { + node, + kind: write ? "write" : "read", + ownerCallableId, + objectBindingId: expressionBindingId(node.object, state), + targetBindingId: null, + propertyName: name, + }, + state, + output, + ); +}; + +const collectDestructuring = ( + node: t.VariableDeclarator, + ownerCallableId: string | null, + state: JavaScriptSemanticAnalysisState, + output: JavaScriptSemanticObjectOperation[], +): void => { + if (!t.isObjectPattern(node.id)) return; + const objectBindingId = expressionBindingId(node.init, state); + for (const property of node.id.properties) { + if (!t.isObjectProperty(property)) continue; + const name = propertyName(property.key); + if (name.length === 0) continue; + const target = bindingIdentifier(property.value); + addObjectOperation( + { + node: property, + kind: "destructure", + ownerCallableId, + objectBindingId, + targetBindingId: + target === null + ? null + : (resolveSemanticBindingState(state, target, target.name) + ?.bindingId ?? null), + propertyName: name, + }, + state, + output, + ); + } +}; + +interface AddObjectOperationInput { + readonly node: t.Node; + readonly kind: JavaScriptSemanticObjectOperation["kind"]; + readonly ownerCallableId: string | null; + readonly objectBindingId: string | null; + readonly targetBindingId: string | null; + readonly propertyName: string | null; +} + +const addObjectOperation = ( + input: AddObjectOperationInput, + state: JavaScriptSemanticAnalysisState, + output: JavaScriptSemanticObjectOperation[], +): void => { + if (output.length >= state.limits.maxObjectOperations) { + reachSemanticLimit(state, "maxObjectOperations"); + return; + } + output.push({ + objectOperationId: `object:${input.kind}:${String(input.node.start ?? -1)}:${String(input.node.end ?? -1)}`, + kind: input.kind, + location: range(input.node), + ownerCallableId: input.ownerCallableId, + objectBindingId: input.objectBindingId, + targetBindingId: input.targetBindingId, + propertyName: input.propertyName, + resolution: + input.kind === "spread" + ? input.objectBindingId === null + ? "partial" + : "complete" + : input.objectBindingId === null || + input.propertyName === null || + (input.kind === "destructure" && input.targetBindingId === null) + ? "partial" + : "complete", + }); +}; + +const expressionBindingId = ( + node: t.Node | null | undefined, + state: JavaScriptSemanticAnalysisState, +): string | null => + t.isIdentifier(node) + ? (resolveSemanticBindingState(state, node, node.name)?.bindingId ?? null) + : null; + +const bindingIdentifier = (node: t.Node): t.Identifier | null => { + if (t.isIdentifier(node)) return node; + if (t.isAssignmentPattern(node) && t.isIdentifier(node.left)) + return node.left; + return null; +}; diff --git a/src/domain/javascriptSemanticPromises.ts b/src/domain/javascriptSemanticPromises.ts new file mode 100644 index 000000000..fdb34c096 --- /dev/null +++ b/src/domain/javascriptSemanticPromises.ts @@ -0,0 +1,473 @@ +import * as t from "@babel/types"; + +import type { + JavaScriptSemanticCallable, + JavaScriptSemanticPromiseOperation, +} from "./javascriptSemanticIr.js"; +import { + reachSemanticLimit, + semanticCallableIdForNode, + semanticStaticPropertyName, +} from "./javascriptSemanticProjection.js"; +import { + resolveSemanticBindingState, + semanticResolutionBlocked, + type JavaScriptSemanticAnalysisState, +} from "./javascriptSemanticState.js"; +import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; +import { + compareCodePoints, + range, + sourceRangesEqual, +} from "./javascriptStaticAnalysisHelpers.js"; + +type PromiseMethod = JavaScriptSemanticPromiseOperation["method"]; +type PromiseKind = JavaScriptSemanticPromiseOperation["kind"]; + +interface PromiseCandidate { + readonly node: t.Node; + readonly kind: PromiseKind; + readonly method: PromiseMethod; + readonly promiseId: string; + readonly ownerCallableId: string | null; + readonly ancestors: readonly t.Node[]; +} + +interface PromiseResolution { + readonly promiseIds: readonly string[]; + readonly status: JavaScriptSemanticPromiseOperation["sourceResolution"]; +} + +interface PromiseOwnership { + readonly ownership: JavaScriptSemanticPromiseOperation["ownership"]; + readonly ownerBindingId: string | null; + readonly returnSiteId: string | null; +} + +interface PromiseExpressionResolutionContext { + readonly current: PromiseCandidate; + readonly candidateByNode: WeakMap; + readonly state: JavaScriptSemanticAnalysisState; + readonly seenBindings: ReadonlySet; + readonly depth: number; +} + +interface PromiseOwnershipContext { + readonly candidate: PromiseCandidate; + readonly candidateByNode: WeakMap; + readonly state: JavaScriptSemanticAnalysisState; + readonly callables: readonly JavaScriptSemanticCallable[]; +} + +/** Recover explicit Promise construction, chaining, aggregation, and ownership. */ +export const collectJavaScriptSemanticPromises = ( + program: t.Program, + state: JavaScriptSemanticAnalysisState, + callables: readonly JavaScriptSemanticCallable[], +): JavaScriptSemanticPromiseOperation[] => { + const candidates = collectCandidates(program, state, callables); + const candidateByNode = new WeakMap(); + for (const candidate of candidates) + candidateByNode.set(candidate.node, candidate); + return candidates.map((candidate) => { + const ownership = promiseOwnership( + candidate, + candidateByNode, + state, + callables, + ); + const sources = promiseSources(candidate, candidateByNode, state); + return { + promiseId: candidate.promiseId, + kind: candidate.kind, + method: candidate.method, + location: range(candidate.node), + ownerCallableId: candidate.ownerCallableId, + ...ownership, + sourcePromiseIds: [...new Set(sources.promiseIds)].sort( + compareCodePoints, + ), + sourceResolution: sources.status, + }; + }); +}; + +const collectCandidates = ( + program: t.Program, + state: JavaScriptSemanticAnalysisState, + callables: readonly JavaScriptSemanticCallable[], +): PromiseCandidate[] => { + const output: PromiseCandidate[] = []; + const admittedCallables = new Set( + callables.map(({ callableId }) => callableId), + ); + const callableStack: string[] = []; + const ancestors: t.Node[] = []; + traverseJavaScriptAst(program, { + enter: (node) => { + const callableId = semanticCallableIdForNode(node); + if (callableId !== null && admittedCallables.has(callableId)) + callableStack.push(callableId); + const details = promiseCandidateDetails(node, state); + if (details !== null) { + if (output.length >= state.limits.maxPromiseOperations) + reachSemanticLimit(state, "maxPromiseOperations"); + else + output.push({ + node, + ...details, + promiseId: semanticPromiseId(details.kind, node), + ownerCallableId: callableStack.at(-1) ?? null, + ancestors: [...ancestors], + }); + } + ancestors.push(node); + }, + exit: (node) => { + ancestors.pop(); + const callableId = semanticCallableIdForNode(node); + if (callableId !== null && callableStack.at(-1) === callableId) + callableStack.pop(); + }, + }); + return output; +}; + +const promiseCandidateDetails = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, +): Pick | null => { + if ( + t.isNewExpression(node) && + t.isIdentifier(node.callee, { name: "Promise" }) && + isUnshadowedGlobalPromise(node.callee, state) + ) + return { kind: "constructor", method: "new" }; + if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) { + const member = memberCallee(node); + if (member === null) return null; + const method = semanticStaticPropertyName(member.property, member.computed); + if ( + t.isIdentifier(member.object, { name: "Promise" }) && + isUnshadowedGlobalPromise(member.object, state) + ) { + if (method === "all" || method === "allSettled") + return { kind: "aggregate", method }; + if (method === "resolve" || method === "reject") + return { kind: "static", method }; + } + if (method === "then" || method === "catch" || method === "finally") + return { kind: "chain", method }; + } + if (t.isAwaitExpression(node) && !isPromiseProducer(node.argument, state)) + return { kind: "awaited-expression", method: "await" }; + return null; +}; + +const isUnshadowedGlobalPromise = ( + node: t.Identifier, + state: JavaScriptSemanticAnalysisState, +): boolean => + resolveSemanticBindingState(state, node, "Promise") === undefined && + !semanticResolutionBlocked(state, node, "Promise"); + +const isPromiseProducer = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, +): boolean => promiseCandidateDetails(unwrapExpression(node), state) !== null; + +const semanticPromiseId = (kind: PromiseKind, node: t.Node): string => + `promise:${kind}:${String(node.start ?? -1)}:${String(node.end ?? -1)}`; + +const memberCallee = ( + node: t.CallExpression | t.OptionalCallExpression, +): t.MemberExpression | t.OptionalMemberExpression | null => + t.isMemberExpression(node.callee) || t.isOptionalMemberExpression(node.callee) + ? node.callee + : null; + +const promiseOwnership = ( + candidate: PromiseCandidate, + candidateByNode: WeakMap, + state: JavaScriptSemanticAnalysisState, + callables: readonly JavaScriptSemanticCallable[], +): PromiseOwnership => { + if (candidate.kind === "awaited-expression") return emptyOwnership("awaited"); + const context: PromiseOwnershipContext = { + candidate, + candidateByNode, + state, + callables, + }; + for (let index = candidate.ancestors.length - 1; index >= 0; index -= 1) { + const ancestor = candidate.ancestors[index]; + if (ancestor === undefined) continue; + const ownership = ownershipAtAncestor(ancestor, context); + if (ownership === "boundary") break; + if (ownership !== null) return ownership; + } + return emptyOwnership("unknown"); +}; + +const ownershipAtAncestor = ( + ancestor: t.Node, + context: PromiseOwnershipContext, +): PromiseOwnership | "boundary" | null => { + const { candidate, candidateByNode, state, callables } = context; + if (t.isAwaitExpression(ancestor)) return emptyOwnership("awaited"); + const outer = candidateByNode.get(ancestor); + if ( + outer !== undefined && + outer.ownerCallableId === candidate.ownerCallableId && + outerConsumesCandidate(outer, candidate) + ) + return outer.kind === "chain" + ? emptyOwnership("chained") + : emptyOwnership("aggregated"); + const assigned = assignedPromiseBinding(ancestor, state); + if (assigned !== undefined) + return { + ownership: "assigned", + ownerBindingId: assigned, + returnSiteId: null, + }; + if (t.isReturnStatement(ancestor)) + return returnedPromiseOwnership( + candidate.ownerCallableId, + range(ancestor), + callables, + ); + if (t.isExpressionStatement(ancestor)) return emptyOwnership("detached"); + const callableId = semanticCallableIdForNode(ancestor); + if (callableId === null || callableId !== candidate.ownerCallableId) + return null; + return t.isArrowFunctionExpression(ancestor) && + ancestor.body === candidate.node + ? returnedPromiseOwnership( + candidate.ownerCallableId, + range(candidate.node), + callables, + ) + : "boundary"; +}; + +const assignedPromiseBinding = ( + ancestor: t.Node, + state: JavaScriptSemanticAnalysisState, +): string | null | undefined => { + if ( + t.isVariableDeclarator(ancestor) && + ancestor.init !== null && + t.isIdentifier(ancestor.id) + ) + return ( + resolveSemanticBindingState(state, ancestor.id, ancestor.id.name) + ?.bindingId ?? null + ); + if (t.isAssignmentExpression(ancestor) && t.isIdentifier(ancestor.left)) + return ( + resolveSemanticBindingState(state, ancestor.left, ancestor.left.name) + ?.bindingId ?? null + ); + return undefined; +}; + +const returnedPromiseOwnership = ( + callableId: string | null, + location: JavaScriptSemanticPromiseOperation["location"], + callables: readonly JavaScriptSemanticCallable[], +): PromiseOwnership => ({ + ownership: "returned", + ownerBindingId: null, + returnSiteId: matchingReturnSite(callableId, location, callables), +}); + +const outerConsumesCandidate = ( + outer: PromiseCandidate, + candidate: PromiseCandidate, +): boolean => { + if ( + outer.kind === "chain" && + (t.isCallExpression(outer.node) || t.isOptionalCallExpression(outer.node)) + ) { + const member = memberCallee(outer.node); + return member !== null && containsNode(member.object, candidate.node); + } + if ( + outer.kind === "aggregate" && + (t.isCallExpression(outer.node) || t.isOptionalCallExpression(outer.node)) + ) { + const input = outer.node.arguments[0]; + return t.isNode(input) && containsNode(input, candidate.node); + } + return false; +}; + +const containsNode = (outer: t.Node, inner: t.Node): boolean => + outer.start !== null && + outer.start !== undefined && + outer.end !== null && + outer.end !== undefined && + inner.start !== null && + inner.start !== undefined && + inner.end !== null && + inner.end !== undefined && + outer.start <= inner.start && + outer.end >= inner.end; + +const emptyOwnership = ( + ownership: JavaScriptSemanticPromiseOperation["ownership"], +): PromiseOwnership => ({ + ownership, + ownerBindingId: null, + returnSiteId: null, +}); + +const matchingReturnSite = ( + callableId: string | null, + location: JavaScriptSemanticPromiseOperation["location"], + callables: readonly JavaScriptSemanticCallable[], +): string | null => + callables + .find(({ callableId: candidate }) => candidate === callableId) + ?.returnSites.find(({ location: candidate }) => + sourceRangesEqual(candidate, location), + )?.returnSiteId ?? null; + +const promiseSources = ( + candidate: PromiseCandidate, + candidateByNode: WeakMap, + state: JavaScriptSemanticAnalysisState, +): PromiseResolution => { + if (candidate.kind === "chain") { + const member = + t.isCallExpression(candidate.node) || + t.isOptionalCallExpression(candidate.node) + ? memberCallee(candidate.node) + : null; + return member === null + ? unresolvedPromise() + : resolvePromiseExpression( + member.object, + promiseResolutionContext(candidate, candidateByNode, state), + ); + } + if (candidate.kind === "aggregate") + return aggregateSources(candidate, candidateByNode, state); + if (candidate.kind === "awaited-expression") + return t.isAwaitExpression(candidate.node) + ? resolvePromiseExpression( + candidate.node.argument, + promiseResolutionContext(candidate, candidateByNode, state), + ) + : unresolvedPromise(); + return { promiseIds: [], status: "complete" }; +}; + +const aggregateSources = ( + candidate: PromiseCandidate, + candidateByNode: WeakMap, + state: JavaScriptSemanticAnalysisState, +): PromiseResolution => { + if ( + !t.isCallExpression(candidate.node) && + !t.isOptionalCallExpression(candidate.node) + ) + return unresolvedPromise(); + const input = candidate.node.arguments[0]; + if (!t.isArrayExpression(input)) return unresolvedPromise(); + const resolutions = input.elements.map((element) => { + if (element === null) return completePromise(); + if (t.isSpreadElement(element)) return unresolvedPromise(); + if (isObviouslyNonPromise(element)) return completePromise(); + return resolvePromiseExpression( + element, + promiseResolutionContext(candidate, candidateByNode, state), + ); + }); + return combinePromiseResolutions(resolutions); +}; + +const resolvePromiseExpression = ( + rawNode: t.Node, + context: PromiseExpressionResolutionContext, +): PromiseResolution => { + const { current, candidateByNode, state, seenBindings, depth } = context; + if (depth >= state.limits.maxValueDepth) return unresolvedPromise(); + const node = unwrapExpression(rawNode); + const direct = candidateByNode.get(node); + if (direct !== undefined && direct.promiseId !== current.promiseId) + return { promiseIds: [direct.promiseId], status: "complete" }; + if (!t.isIdentifier(node)) return unresolvedPromise(); + const binding = resolveSemanticBindingState(state, node, node.name); + if ( + binding === undefined || + binding.mutable || + binding.initializers.length !== 1 || + seenBindings.has(binding.bindingId) + ) + return unresolvedPromise(); + const initializer = binding.initializers[0]; + if (initializer === undefined || initializer.projection.length > 0) + return unresolvedPromise(); + const nestedSeen = new Set(seenBindings); + nestedSeen.add(binding.bindingId); + return resolvePromiseExpression(initializer.node, { + ...context, + seenBindings: nestedSeen, + depth: depth + 1, + }); +}; + +const promiseResolutionContext = ( + current: PromiseCandidate, + candidateByNode: WeakMap, + state: JavaScriptSemanticAnalysisState, +): PromiseExpressionResolutionContext => ({ + current, + candidateByNode, + state, + seenBindings: new Set(), + depth: 0, +}); + +const unwrapExpression = (node: t.Node): t.Node => { + if ( + t.isParenthesizedExpression(node) || + t.isTSAsExpression(node) || + t.isTSSatisfiesExpression(node) || + t.isTSNonNullExpression(node) || + t.isTypeCastExpression(node) + ) + return unwrapExpression(node.expression); + return node; +}; + +const combinePromiseResolutions = ( + resolutions: readonly PromiseResolution[], +): PromiseResolution => ({ + promiseIds: resolutions.flatMap(({ promiseIds }) => promiseIds), + status: resolutions.some(({ status }) => status === "unresolved") + ? resolutions.some(({ status }) => status === "complete") + ? "partial" + : "unresolved" + : resolutions.some(({ status }) => status === "partial") + ? "partial" + : "complete", +}); + +const completePromise = (): PromiseResolution => ({ + promiseIds: [], + status: "complete", +}); + +const unresolvedPromise = (): PromiseResolution => ({ + promiseIds: [], + status: "unresolved", +}); + +const isObviouslyNonPromise = (node: t.Node): boolean => + t.isLiteral(node) || + t.isObjectExpression(node) || + t.isFunction(node) || + t.isClassExpression(node); diff --git a/src/domain/javascriptSemanticResources.ts b/src/domain/javascriptSemanticResources.ts new file mode 100644 index 000000000..3400d1784 --- /dev/null +++ b/src/domain/javascriptSemanticResources.ts @@ -0,0 +1,215 @@ +import * as t from "@babel/types"; + +import type { + JavaScriptSemanticCallable, + JavaScriptSemanticResourceOperation, +} from "./javascriptSemanticIr.js"; +import { semanticStaticPropertyName } from "./javascriptSemanticProjection.js"; +import { + resolveSemanticBindingState, + type JavaScriptSemanticAnalysisState, + type JavaScriptSemanticBindingState, +} from "./javascriptSemanticState.js"; +import { + dataEffectLimitReached, + outerDataEffectBinding, + traverseDataEffects, + type DataEffectTraversalContext, +} from "./javascriptSemanticDataEffectHelpers.js"; +import { range } from "./javascriptStaticAnalysisHelpers.js"; + +const ACQUIRE_METHODS = [ + "open", + "openSync", + "createReadStream", + "createWriteStream", + "connect", + "createConnection", +] as const; +const RELEASE_METHODS = ["close", "destroy", "end"] as const; + +interface ResourceCandidate { + readonly node: t.CallExpression | t.OptionalCallExpression; + readonly method: JavaScriptSemanticResourceOperation["method"]; + readonly ownerCallableId: string | null; + readonly resultBindingId: string | null; +} + +/** Recover built-in filesystem/network acquisition and local release handles. */ +export const collectJavaScriptSemanticResources = ( + program: t.Program, + state: JavaScriptSemanticAnalysisState, + callables: readonly JavaScriptSemanticCallable[], +): JavaScriptSemanticResourceOperation[] => { + const context: DataEffectTraversalContext = { + state, + callableIds: new Set(callables.map(({ callableId }) => callableId)), + callableStack: [], + ancestors: [], + }; + const acquisitions = collectAcquisitions(program, context); + return [ + ...acquisitions.map(immutableAcquisition), + ...collectReleases(program, context, acquisitions), + ]; +}; + +const collectAcquisitions = ( + program: t.Program, + context: DataEffectTraversalContext, +): ResourceCandidate[] => { + const output: ResourceCandidate[] = []; + traverseDataEffects(program, context, (node) => { + if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return; + const method = acquisitionMethod(node.callee, context.state); + if (method === null) return; + if (resourceLimitReached(output.length, context.state)) return; + output.push({ + node, + method, + ownerCallableId: context.callableStack.at(-1) ?? null, + resultBindingId: outerDataEffectBinding(node, context), + }); + }); + return output; +}; + +const collectReleases = ( + program: t.Program, + context: DataEffectTraversalContext, + acquisitions: readonly ResourceCandidate[], +): JavaScriptSemanticResourceOperation[] => { + const byBinding = new Map(); + for (const acquisition of acquisitions) { + if (acquisition.resultBindingId === null) continue; + const existing = byBinding.get(acquisition.resultBindingId) ?? []; + existing.push(acquisition); + byBinding.set(acquisition.resultBindingId, existing); + } + const output: JavaScriptSemanticResourceOperation[] = []; + traverseDataEffects(program, context, (node) => { + if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return; + const member = memberCallee(node); + if (member === null || !t.isIdentifier(member.object)) return; + const method = RELEASE_METHODS.find( + (candidate) => + candidate === + semanticStaticPropertyName(member.property, member.computed), + ); + if (method === undefined) return; + const binding = resolveSemanticBindingState( + context.state, + member.object, + member.object.name, + ); + const linked = + binding === undefined ? [] : (byBinding.get(binding.bindingId) ?? []); + if (linked.length === 0) return; + if ( + resourceLimitReached(acquisitions.length + output.length, context.state) + ) + return; + output.push({ + resourceId: `resource:release:${String(node.start ?? -1)}:${String(node.end ?? -1)}`, + kind: "release", + method, + location: range(node), + ownerCallableId: context.callableStack.at(-1) ?? null, + resultBindingId: binding?.bindingId ?? null, + linkedResourceIds: linked.map(resourceCandidateId).sort(), + resolution: linked.length === 1 ? "complete" : "partial", + }); + }); + return output; +}; + +const immutableAcquisition = ( + candidate: ResourceCandidate, +): JavaScriptSemanticResourceOperation => ({ + resourceId: resourceCandidateId(candidate), + kind: "acquire", + method: candidate.method, + location: range(candidate.node), + ownerCallableId: candidate.ownerCallableId, + resultBindingId: candidate.resultBindingId, + linkedResourceIds: [], + resolution: "complete", +}); + +const acquisitionMethod = ( + callee: t.Node, + state: JavaScriptSemanticAnalysisState, +): JavaScriptSemanticResourceOperation["method"] | null => { + if (t.isIdentifier(callee)) { + const binding = resolveSemanticBindingState(state, callee, callee.name); + return methodFromBinding(binding); + } + if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) + return null; + const method = ACQUIRE_METHODS.find( + (candidate) => + candidate === + semanticStaticPropertyName(callee.property, callee.computed), + ); + if (method === undefined || !t.isIdentifier(callee.object)) return null; + const binding = resolveSemanticBindingState( + state, + callee.object, + callee.object.name, + ); + return resourceNamespace(binding) ? method : null; +}; + +const methodFromBinding = ( + binding: JavaScriptSemanticBindingState | undefined, +): JavaScriptSemanticResourceOperation["method"] | null => { + for (const { specifier, importedPath } of binding?.directOrigins ?? []) { + if (!resourceModule(specifier)) continue; + const method = ACQUIRE_METHODS.find( + (candidate) => candidate === importedPath.at(-1), + ); + if (method !== undefined) return method; + } + return null; +}; + +const resourceNamespace = ( + binding: JavaScriptSemanticBindingState | undefined, +): boolean => + binding?.directOrigins.some( + ({ specifier, importedPath }) => + resourceModule(specifier) && importedPath.length === 0, + ) ?? false; + +const resourceModule = (specifier: string): boolean => + [ + "fs", + "fs/promises", + "net", + "node:fs", + "node:fs/promises", + "node:net", + "node:tls", + "tls", + ].includes(specifier); + +const resourceLimitReached = ( + retained: number, + state: JavaScriptSemanticAnalysisState, +): boolean => + dataEffectLimitReached( + retained, + state.limits.maxResourceOperations, + "maxResourceOperations", + state, + ); + +const resourceCandidateId = (candidate: ResourceCandidate): string => + `resource:acquire:${String(candidate.node.start ?? -1)}:${String(candidate.node.end ?? -1)}`; + +const memberCallee = ( + node: t.CallExpression | t.OptionalCallExpression, +): t.MemberExpression | t.OptionalMemberExpression | null => + t.isMemberExpression(node.callee) || t.isOptionalMemberExpression(node.callee) + ? node.callee + : null; diff --git a/src/domain/javascriptStaticAnalysis.ts b/src/domain/javascriptStaticAnalysis.ts index 2cbc0b334..1112bfc20 100644 --- a/src/domain/javascriptStaticAnalysis.ts +++ b/src/domain/javascriptStaticAnalysis.ts @@ -16,7 +16,10 @@ import { inspectRouteProperty, inspectRoleProperty, } from "./javascriptStaticAnalysisCalls.js"; -import { inspectBundlerRegistration } from "./javascriptStaticAnalysisBundler.js"; +import { + inspectBundlerRegistration, + inspectEsbuildWrapper, +} from "./javascriptStaticAnalysisBundler.js"; import { finalizeLocatedFindings } from "./javascriptStaticAnalysisFindings.js"; import { createJavaScriptAnalysisAccumulator, @@ -167,8 +170,9 @@ const inspectNode = ( inspectElectronStaticNode(node, findings); if (t.isCallExpression(node)) { inspectBundlerRegistration(source, node, accumulator, limits); - inspectCall(node, findings); - } else if (t.isNewExpression(node)) inspectCall(node, findings); + inspectEsbuildWrapper(source, node, accumulator, limits); + inspectCall(source, node, findings); + } else if (t.isNewExpression(node)) inspectCall(source, node, findings); if ( (t.isImportDeclaration(node) || t.isExportAllDeclaration(node)) && node.source !== undefined diff --git a/src/domain/javascriptStaticAnalysisBundler.ts b/src/domain/javascriptStaticAnalysisBundler.ts index 6852b4a0c..1316e6756 100644 --- a/src/domain/javascriptStaticAnalysisBundler.ts +++ b/src/domain/javascriptStaticAnalysisBundler.ts @@ -22,6 +22,7 @@ import { import type { JavaScriptAnalysisAccumulator as AnalysisAccumulator } from "./javascriptStaticAnalysisState.js"; import type { JavaScriptBundlerModule, + JavaScriptBundlerRegistration, JavaScriptStaticAnalysisLimits, } from "./javascriptStaticAnalysisTypes.js"; @@ -105,6 +106,56 @@ export const inspectBundlerRegistration = ( ); }; +/** Inspect esbuild helper wrappers and recover their literal module tables. */ +export const inspectEsbuildWrapper = ( + source: string, + call: t.CallExpression, + accumulator: AnalysisAccumulator, + limits: JavaScriptStaticAnalysisLimits, +): void => { + const name = calleeName(call.callee); + const wrapperKind = + name === "__commonJS" || name.endsWith(".__commonJS") + ? "commonjs" + : name === "__esm" || name.endsWith(".__esm") + ? "esm" + : null; + const table = call.arguments[0]; + if (wrapperKind === null || !t.isObjectExpression(table)) return; + const recovered = recoverBundlerModules(source, table, accumulator, limits); + if (recovered.modules.length === 0) return; + if (recovered.omittedAsyncChunkKeys > 0) { + accumulator.droppedFindings += recovered.omittedAsyncChunkKeys; + accumulator.structuralTruncation = true; + } + accumulator.unknownFindings += recovered.unknownAsyncChunkKeys; + const registration: JavaScriptBundlerRegistration = { + bundler: "esbuild", + runtime: `esbuild-${wrapperKind}`, + chunk_keys: [`${wrapperKind}@${String(call.start ?? 0)}`], + omitted_chunk_keys: 0, + unknown_chunk_keys: 0, + runtime_require_name: null, + runtime_module_cache_status: "not-observed", + entry_module_keys: [], + omitted_entry_module_keys: 0, + unknown_entry_module_keys: 0, + async_chunk_keys: recovered.asyncChunkKeys, + omitted_async_chunk_keys: recovered.omittedAsyncChunkKeys, + unknown_async_chunk_keys: recovered.unknownAsyncChunkKeys, + modules: recovered.modules.sort((left, right) => + compareCodePoints(left.module_key, right.module_key), + ), + location: range(call), + }; + addBoundedFinding( + accumulator, + `registration\0${registrationKey(registration)}`, + limits.maxFindings, + () => accumulator.registrations.push(registration), + ); +}; + interface RecoveredBundlerModules { readonly modules: JavaScriptBundlerModule[]; readonly asyncChunkKeys: readonly string[]; diff --git a/src/domain/javascriptStaticAnalysisCalls.ts b/src/domain/javascriptStaticAnalysisCalls.ts index 6314eac2a..94843c727 100644 --- a/src/domain/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascriptStaticAnalysisCalls.ts @@ -30,6 +30,7 @@ import type { /** Inspect one call or new expression for references, endpoints, storage, and roles. */ export const inspectCall = ( + source: string, node: t.CallExpression | t.NewExpression, context: FindingContext, ): void => { @@ -52,6 +53,8 @@ export const inspectCall = ( kind: "dynamic-import", specifier: chunkLoad.specifier, }); + for (const specifier of vitePreloadDependencySpecifiers(source, node, name)) + addReference(context, { node, kind: "dynamic-import", specifier }); if (name === "Worker" || name.endsWith(".Worker")) addReference(context, { node, kind: "worker", specifier: first }); if (name.endsWith("serviceWorker.register")) @@ -118,6 +121,33 @@ const chunkLoadSpecifier = ( return { specifier: prefixedArgument("chunk:", node.arguments[0]) }; }; +const vitePreloadDependencySpecifiers = ( + source: string, + node: t.CallExpression | t.NewExpression, + name: string, +): readonly string[] => { + if (name !== "__vite__mapDeps" || !t.isArrayExpression(node.arguments[0])) + return []; + const indexes = node.arguments[0].elements.flatMap((element) => + t.isNumericLiteral(element) ? [element.value] : [], + ); + if (indexes.length !== node.arguments[0].elements.length) return []; + const table = vitePreloadDependencyTable(source); + return indexes.flatMap((index) => table[index] ?? []); +}; + +const vitePreloadDependencyTable = (source: string): readonly string[] => { + const match = + /\.\s*f\s*\|\|\s*\(\s*[^)]*?\.f\s*=\s*\[([\s\S]*?)\]\s*\)/u.exec(source); + const body = match?.[1]; + if (body === undefined) return []; + return [ + ...body.matchAll(/"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'/gu), + ] + .map((literal) => (literal[1] ?? literal[2] ?? "").slice(0, 4_096)) + .slice(0, 256); +}; + const inspectEndpointCall = ( node: t.CallExpression | t.NewExpression, name: string, diff --git a/src/domain/javascriptStaticAnalysisHelpers.ts b/src/domain/javascriptStaticAnalysisHelpers.ts index a3e75d459..fdfbd215e 100644 --- a/src/domain/javascriptStaticAnalysisHelpers.ts +++ b/src/domain/javascriptStaticAnalysisHelpers.ts @@ -388,6 +388,17 @@ export const rangeForOffsets = ( end: pointForOffset(source, end), }); +/** Compare two exact source ranges. */ +export const sourceRangesEqual = ( + left: JavaScriptSourceRange | null, + right: JavaScriptSourceRange, +): boolean => + left !== null && + left.start.line === right.start.line && + left.start.column === right.start.column && + left.end.line === right.end.line && + left.end.column === right.end.column; + const pointForOffset = (source: string, offset: number) => { const before = source.slice(0, offset); const lines = before.split("\n"); @@ -450,6 +461,8 @@ const vendorPatterns = [ { name: "webpack", patterns: ["__webpack_require__", "webpackChunk"] }, { name: "Rspack", patterns: ["__webpack_require__.f", "rspackChunk"] }, { name: "Vite", patterns: ["__vite__", "import.meta.hot"] }, + { name: "Rollup", patterns: ["ROLLUP_FILE_URL", "import.meta.ROLLUP"] }, + { name: "esbuild", patterns: ["__commonJS", "__esm"] }, { name: "React", patterns: ["React.createElement", "react/jsx-runtime"] }, { name: "Vue", patterns: ["__VUE__", "createApp("] }, { name: "Next.js", patterns: ["__NEXT_DATA__", "/_next/"] }, diff --git a/src/domain/javascriptStaticAnalysisTypes.ts b/src/domain/javascriptStaticAnalysisTypes.ts index 819bce044..4f3902ce3 100644 --- a/src/domain/javascriptStaticAnalysisTypes.ts +++ b/src/domain/javascriptStaticAnalysisTypes.ts @@ -67,9 +67,9 @@ export interface JavaScriptBundlerModule { readonly location: JavaScriptSourceRange; } -/** One statically recognized Webpack/Rspack chunk registration. */ +/** One statically recognized bundler chunk or module registration. */ export interface JavaScriptBundlerRegistration { - readonly bundler: "webpack" | "rspack"; + readonly bundler: "webpack" | "rspack" | "esbuild"; readonly runtime: string; readonly chunk_keys: readonly string[]; readonly omitted_chunk_keys: number; diff --git a/src/domain/permissionPolicy.ts b/src/domain/permissionPolicy.ts index af0d44098..4642becaa 100644 --- a/src/domain/permissionPolicy.ts +++ b/src/domain/permissionPolicy.ts @@ -6,7 +6,9 @@ import { err, ok, type Result } from "./result.js"; export type PermissionCapability = | "process_capture" | "browser_observe" + | "browser_automate" | "electron_observe" + | "v8_inspector_observe" | "evidence_read" | "evidence_write" | "investigation_input" diff --git a/src/domain/processCapture.ts b/src/domain/processCapture.ts index 5b1fe3aa5..60a3f1da6 100644 --- a/src/domain/processCapture.ts +++ b/src/domain/processCapture.ts @@ -34,12 +34,12 @@ export interface RenderedTerminalFrame { readonly serialized_state: string; } -/** Scheduled input, resize, or signal with its observed dispatch outcome. */ +/** Scheduled terminal interaction with its observed dispatch outcome. */ export interface InteractionEvent { readonly sequence: number; readonly scheduled_at_ms: number; readonly dispatched_at_ms: number; - readonly type: "input" | "resize" | "signal"; + readonly type: "input" | "resize" | "stdin_close" | "signal"; readonly data: string; readonly outcome: "dispatched" | "target_exited" | "failed"; } @@ -307,7 +307,7 @@ export const processCaptureSchema: z.ZodType = z.object({ sequence: z.number().int().nonnegative(), scheduled_at_ms: z.number().int().nonnegative(), dispatched_at_ms: z.number().int().nonnegative(), - type: z.enum(["input", "resize", "signal"]), + type: z.enum(["input", "resize", "stdin_close", "signal"]), data: z.string(), outcome: z.enum(["dispatched", "target_exited", "failed"]), }), @@ -447,7 +447,13 @@ export const processCaptureSchema: z.ZodType = z.object({ .max(PROCESS_REACTIVE_LIMITS.actionsPerTransition), action_types: z .array( - z.enum(["send_input", "resize", "send_signal", "checkpoint"]), + z.enum([ + "send_input", + "resize", + "close_stdin", + "send_signal", + "checkpoint", + ]), ) .max(PROCESS_REACTIVE_LIMITS.actionsPerTransition), }), diff --git a/src/domain/processReactiveScenario.ts b/src/domain/processReactiveScenario.ts index 0014faf71..e641622c7 100644 --- a/src/domain/processReactiveScenario.ts +++ b/src/domain/processReactiveScenario.ts @@ -190,6 +190,7 @@ const processReactiveActionSchema = z.discriminatedUnion("type", [ columns: z.number().int().min(1).max(1_000), rows: z.number().int().min(1).max(1_000), }), + z.strictObject({ type: z.literal("close_stdin") }), z.strictObject({ type: z.literal("send_signal"), target: signalTargetSchema, diff --git a/src/domain/processReactiveTransition.ts b/src/domain/processReactiveTransition.ts index b9f6a02c4..50fa92775 100644 --- a/src/domain/processReactiveTransition.ts +++ b/src/domain/processReactiveTransition.ts @@ -121,6 +121,23 @@ const applyProcessReactiveTransition = (input: { }; }; +const terminalControlObservationMatches = ( + action: Exclude< + ProcessReactiveScenario["states"][number]["on"][number]["actions"][number], + { readonly type: "checkpoint" | "send_input" } + >, + payload: Readonly>, +): boolean => { + if (action.type === "resize") + return ( + payload["type"] === "resize" && + payload["data"] === `${String(action.columns)}x${String(action.rows)}` + ); + if (action.type === "close_stdin") + return payload["type"] === "stdin_close" && payload["data"] === ""; + return payload["type"] === "signal" && payload["data"] === action.signal; +}; + const effectObservationMatchesAction = ( action: ProcessReactiveScenario["states"][number]["on"][number]["actions"][number], observation: ProcessObservation, @@ -160,12 +177,7 @@ const effectObservationMatchesAction = ( committedSensitiveInputs, ) ); - if (action.type === "resize") - return ( - payload["type"] === "resize" && - payload["data"] === `${String(action.columns)}x${String(action.rows)}` - ); - return payload["type"] === "signal" && payload["data"] === action.signal; + return terminalControlObservationMatches(action, payload); }; const expectedInputEvidence = ( diff --git a/src/domain/reconstructionObligationLedgerSchemas.ts b/src/domain/reconstructionObligationLedgerSchemas.ts new file mode 100644 index 000000000..08245c0b2 --- /dev/null +++ b/src/domain/reconstructionObligationLedgerSchemas.ts @@ -0,0 +1,344 @@ +import canonicalize from "canonicalize"; +import { z } from "zod"; + +import { evidenceBundleSchema } from "./evidenceBundle.js"; + +const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u); +const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u); +const stableIdSchema = z.string().regex(/^[A-Za-z][A-Za-z0-9._:/-]{0,199}$/u); +const boundedTextSchema = z.string().trim().min(1).max(4_096); + +export const obligationStatusSchema = z.enum([ + "unowned", + "characterized", + "implemented", + "verified", + "contradicted", + "blocked", + "out-of-scope", + "unknown", +]); + +export const obligationLayerSchema = z.enum([ + "cli", + "protocol", + "electron", + "persistence", + "process", + "runtime", + "packaging", + "native-abi", + "application", + "other", +]); + +export const obligationCaseKindSchema = z.enum([ + "positive", + "negative", + "malformed", + "cancellation", + "teardown", +]); + +export const obligationProofAuthoritySchema = z.enum([ + "unit", + "integration", + "protocol", + "renderer", + "packaged-process", + "native-abi", + "live-observation", + "external", +]); + +export const obligationOriginalAuthoritySchema = z.enum([ + "static", + "runtime", + "process", + "external", +]); + +const obligationTargetSchema = z.strictObject({ + artifact_sha256: digestSchema.nullable(), + application_node_id: z + .string() + .regex(/^jag_node_[a-f0-9]{64}$/u) + .nullable(), + semantic_node_id: z + .string() + .regex(/^jsrg_node_[a-f0-9]{64}$/u) + .nullable(), + location: boundedTextSchema, +}); + +const authorityReferenceSchema = z.strictObject({ + evidence_id: evidenceIdSchema, + authority: z.enum([ + "shipped-artifact", + "controlled-replay", + "historical-reference", + "external-service", + "analyst-inference", + ]), + state: z.enum(["candidate", "observed", "reviewed", "unknown"]), + location: boundedTextSchema, +}); + +const observedCaseSchema = z.strictObject({ + kind: obligationCaseKindSchema, + evidence_id: evidenceIdSchema, + location: boundedTextSchema, +}); + +const ownerBindingSchema = z.strictObject({ + module_path: boundedTextSchema, + symbol: z.string().trim().min(1).max(1_024), + owner_sha256: digestSchema, +}); + +const parserTypeBindingSchema = z.strictObject({ + parser: boundedTextSchema, + schema: boundedTextSchema, + domain_type: boundedTextSchema, +}); + +const fixtureBindingSchema = z.strictObject({ + fixture_id: stableIdSchema, + case_kind: obligationCaseKindSchema, + authority: obligationProofAuthoritySchema, + evidence_ids: z.array(evidenceIdSchema).min(1).max(100), +}); + +const nondeterminismSchema = z.strictObject({ + mode: z.enum(["total-order", "partial-order", "finite-traces"]), + specification: boundedTextSchema, +}); + +const verifierBindingSchema = z.strictObject({ + verifier_id: stableIdSchema, + claim_id: stableIdSchema, + command: boundedTextSchema, + authority: obligationProofAuthoritySchema, + status: z.enum(["pass", "fail", "unknown", "unsupported", "skipped"]), + result_evidence_id: evidenceIdSchema, + enumerated_obligation_ids: z.array(stableIdSchema).min(1).max(10_000), + nondeterminism: nondeterminismSchema, +}); + +export const reconstructionObligationManifestBindingSchema = z.strictObject({ + obligation_id: stableIdSchema, + owner: ownerBindingSchema, + parser_type: parserTypeBindingSchema.nullable(), + original_cases: z.array(observedCaseSchema).max(100), + fixtures: z.array(fixtureBindingSchema).max(100), + verifier: verifierBindingSchema.nullable(), +}); + +export const reconstructionObligationManifestSchema = z.strictObject({ + schema_version: z.literal(1), + bindings: z.array(reconstructionObligationManifestBindingSchema).max(10_000), + contradictions: z + .array( + z.strictObject({ + obligation_id: stableIdSchema, + evidence_ids: z.array(evidenceIdSchema).min(2).max(100), + }), + ) + .max(10_000), +}); + +export const reviewedReconstructionObligationSchema = z.strictObject({ + obligation_id: stableIdSchema, + obligation_version: z.number().int().min(1), + title: z.string().trim().min(1).max(500), + application_layer: obligationLayerSchema, + family: z.string().trim().min(1).max(100), + target: obligationTargetSchema, + required: z.boolean(), + required_case_kinds: z.array(obligationCaseKindSchema).min(1).max(5), + required_original_authority: obligationOriginalAuthoritySchema, + required_fixture_authority: obligationProofAuthoritySchema, + required_verifier_authority: obligationProofAuthoritySchema, + requires_parser_type: z.boolean(), + dependency_obligation_ids: z.array(stableIdSchema).max(1_000), + residual_unknown_ids: z.array(stableIdSchema).max(1_000), + unavailable_authority: z.array(boundedTextSchema).max(100), + required_next_evidence: z.array(boundedTextSchema).max(100), + disposition: z.enum(["active", "blocked", "out-of-scope"]), + review_evidence_ids: z.array(evidenceIdSchema).min(1).max(100), +}); + +const diagnosticSchema = z.strictObject({ + code: z.enum([ + "duplicate-definition", + "missing-owner", + "ambiguous-owner", + "missing-parser-type", + "missing-original-case", + "weak-original-case-authority", + "missing-case", + "weak-fixture-authority", + "missing-verifier", + "verifier-does-not-enumerate", + "missing-verifier-result", + "weak-verifier-authority", + "verifier-failed", + "static-only", + "contradiction", + "contradiction-evidence-missing", + "residual-unknown", + "dependency-missing", + "dependency-open", + "unavailable-authority", + "blocked", + "out-of-scope", + ]), + detail: boundedTextSchema, +}); + +export const reconstructionObligationSchema = z.strictObject({ + obligation_id: stableIdSchema, + obligation_version: z.number().int().min(1), + title: z.string().trim().min(1).max(500), + origin: z.enum(["generated", "reviewed"]), + application_layer: obligationLayerSchema, + family: z.string().trim().min(1).max(100), + target: obligationTargetSchema, + authority_references: z.array(authorityReferenceSchema).min(1).max(1_000), + source_state: z.enum(["candidate", "observed", "reviewed", "unknown"]), + observed_cases: z.array(observedCaseSchema).max(100), + required: z.boolean(), + required_case_kinds: z.array(obligationCaseKindSchema).min(1).max(5), + required_original_authority: obligationOriginalAuthoritySchema, + required_fixture_authority: obligationProofAuthoritySchema, + required_verifier_authority: obligationProofAuthoritySchema, + requires_parser_type: z.boolean(), + dependency_obligation_ids: z.array(stableIdSchema).max(1_000), + residual_unknown_ids: z.array(stableIdSchema).max(1_000), + unavailable_authority: z.array(boundedTextSchema).max(100), + required_next_evidence: z.array(boundedTextSchema).max(100), + binding: reconstructionObligationManifestBindingSchema.nullable(), + status: obligationStatusSchema, + diagnostics: z.array(diagnosticSchema).max(1_000), +}); + +const countBySchema = ( + key: Schema, +): z.ZodArray< + z.ZodObject<{ + key: Schema; + count: z.ZodNumber; + }> +> => + z.array( + z.strictObject({ + key, + count: z.number().int().min(0), + }), + ); + +export const reconstructionObligationLedgerSchema = z.strictObject({ + schema: z.literal("ReconstructionObligationLedger"), + schema_version: z.literal(1), + ledger_id: z.string().regex(/^rol_[a-f0-9]{64}$/u), + closure_digest: digestSchema, + status: z.enum(["ready", "open", "failed", "unknown"]), + coverage: z.strictObject({ + status: z.enum(["complete", "partial"]), + truncated: z.boolean(), + omitted_count: z.number().int().min(0), + max_obligations: z.number().int().min(1), + }), + summary: z.strictObject({ + total: z.number().int().min(0), + required: z.number().int().min(0), + verified: z.number().int().min(0), + required_open: z.number().int().min(0), + by_status: countBySchema(obligationStatusSchema), + by_application_layer: countBySchema(obligationLayerSchema), + by_evidence_authority: countBySchema( + z.enum(["candidate", "observed", "reviewed", "unknown"]), + ), + }), + reports: z.strictObject({ + missing_owner_obligation_ids: z.array(stableIdSchema), + missing_verifier_obligation_ids: z.array(stableIdSchema), + contradicted_obligation_ids: z.array(stableIdSchema), + residual_unknown_ids: z.array(stableIdSchema), + }), + ownership_graph: z.array( + z.strictObject({ + obligation_id: stableIdSchema, + module_path: boundedTextSchema, + symbol: z.string().trim().min(1).max(1_024), + }), + ), + dependency_graph: z.array( + z.strictObject({ + obligation_id: stableIdSchema, + depends_on_obligation_id: stableIdSchema, + }), + ), + obligations: z.array(reconstructionObligationSchema).max(10_000), + evidence_links: z.array(evidenceIdSchema).max(100_000), + limitations: z.array(boundedTextSchema).max(1_000), +}); + +export const reconstructionObligationLedgerInputSchema = z.strictObject({ + evidence_bundle: evidenceBundleSchema, + reviewed_obligations: z + .array(reviewedReconstructionObligationSchema) + .max(10_000), + manifest: reconstructionObligationManifestSchema, + limits: z.strictObject({ + max_obligations: z.number().int().min(1).max(10_000), + }), + page: z.strictObject({ + offset: z.number().int().min(0).max(10_000), + limit: z.number().int().min(1).max(100), + }), +}); + +export const reconstructionObligationLedgerPageSchema = z.strictObject({ + ...reconstructionObligationLedgerSchema.omit({ obligations: true }).shape, + page: z.strictObject({ + offset: z.number().int().min(0), + limit: z.number().int().min(1).max(100), + total: z.number().int().min(0), + returned: z.number().int().min(0), + next_offset: z.number().int().min(0).nullable(), + }), + obligations: z.array(reconstructionObligationSchema).max(100), +}); + +export type ReconstructionObligation = z.infer< + typeof reconstructionObligationSchema +>; +export type ReconstructionObligationLedger = z.infer< + typeof reconstructionObligationLedgerSchema +>; +export type ReconstructionObligationLedgerInput = z.infer< + typeof reconstructionObligationLedgerInputSchema +>; +export type ReconstructionObligationLedgerPage = z.infer< + typeof reconstructionObligationLedgerPageSchema +>; +export type ReviewedReconstructionObligation = z.infer< + typeof reviewedReconstructionObligationSchema +>; +export type ReconstructionObligationManifest = z.infer< + typeof reconstructionObligationManifestSchema +>; + +/** Parse and canonically serialize one complete reconstruction obligation ledger. */ +export const serializeReconstructionObligationLedger = ( + input: unknown, +): string => { + const ledger = reconstructionObligationLedgerSchema.parse(input); + const encoded = canonicalize(ledger); + if (encoded === undefined) + throw new TypeError( + "Reconstruction obligation ledger is not canonical JSON", + ); + return encoded; +}; diff --git a/src/domain/reconstructionReadiness.ts b/src/domain/reconstructionReadiness.ts new file mode 100644 index 000000000..6c2566cea --- /dev/null +++ b/src/domain/reconstructionReadiness.ts @@ -0,0 +1,193 @@ +import { createHash } from "node:crypto"; + +import canonicalize from "canonicalize"; + +import { parseEvidenceBundle } from "./evidenceBundle.js"; +import { readinessFindingOrder } from "./reconstructionReadinessFindingHelpers.js"; +import { + READINESS_STAGE_IDS, + readinessJourneyFindings, +} from "./reconstructionReadinessJourneyFindings.js"; +import { readinessIntegrityFindings } from "./reconstructionReadinessIntegrityFindings.js"; +import { + reconstructionReadinessInputSchema, + reconstructionReadinessReportSchema, + type ReadinessFinding, + type ReconstructionReadinessInput, + type ReconstructionReadinessReport, +} from "./reconstructionReadinessSchemas.js"; + +/** Evaluate one public-contract journey into a deterministic fail-closed report. */ +export const createReconstructionReadinessReport = ( + inputValue: unknown, +): ReconstructionReadinessReport => { + const input = reconstructionReadinessInputSchema.parse(inputValue); + const sourceDigest = digest(sourceValue(input)); + const evidenceIds = new Set( + parseEvidenceBundle(input.evidence_bundle).records.map( + ({ evidence_id: evidenceId }) => evidenceId, + ), + ); + const findings = [ + ...readinessJourneyFindings(input, evidenceIds), + ...readinessIntegrityFindings(input, evidenceIds, sourceDigest), + ].sort(readinessFindingOrder); + const stages = effectiveStages(input, findings); + const status = aggregateStatus(stages); + const semantic = { + source_digest: sourceDigest, + status, + summary: { + stages: stages.length, + required_stages: stages.filter(({ required }) => required).length, + passed_required_stages: stages.filter( + ({ required, status: stageStatus }) => + required && stageStatus === "pass", + ).length, + findings: findings.length, + failed_findings: findings.filter( + ({ status: findingStatus }) => findingStatus === "fail", + ).length, + unknown_findings: findings.filter( + ({ status: findingStatus }) => findingStatus === "unknown", + ).length, + }, + metrics: readinessMetrics(input), + stages, + findings, + evidence_links: [...evidenceIds].sort(), + snapshot: input, + }; + const reportDigest = digest({ + schema: "rea.reconstruction-readiness-report/v1", + ...semantic, + }); + return reconstructionReadinessReportSchema.parse({ + schema: "ReconstructionReadinessReport", + schema_version: 1, + report_id: `rr_${reportDigest}`, + report_digest: reportDigest, + ...semantic, + }); +}; + +const effectiveStages = ( + input: ReconstructionReadinessInput, + findings: readonly ReadinessFinding[], +): ReconstructionReadinessReport["stages"] => + READINESS_STAGE_IDS.map((stageId) => { + const stage = input.stages.find(({ stage_id: id }) => id === stageId); + const stageFindings = findings.filter(({ stage_id: id }) => id === stageId); + const status = stageFindings.some( + ({ status: findingStatus }) => findingStatus === "fail", + ) + ? "fail" + : stage !== undefined && stage.status !== "pass" + ? stage.status + : stageFindings.length > 0 + ? "unknown" + : (stage?.status ?? "fail"); + return stage === undefined + ? { + stage_id: stageId, + required: true, + status, + capability_issue: null, + next_action: "Supply the missing conformance stage.", + evidence_ids: [], + checks: [], + } + : { ...stage, required: true, status }; + }); + +const aggregateStatus = ( + stages: ReconstructionReadinessReport["stages"], +): ReconstructionReadinessReport["status"] => { + const required = stages.filter(({ required }) => required); + if (required.some(({ status }) => status === "fail")) return "fail"; + if (required.every(({ status }) => status === "pass")) return "pass"; + for (const status of [ + "unknown", + "truncated", + "unsupported", + "skipped", + ] as const) + if (required.some(({ status: stageStatus }) => stageStatus === status)) + return status; + return "unknown"; +}; + +const readinessMetrics = ( + input: ReconstructionReadinessInput, +): ReconstructionReadinessReport["metrics"] => { + const selectedFixtures = input.fixtures.filter((fixture) => + input.workflow_candidates.some( + ({ fixture_id: fixtureId, selected, compatibility }) => + fixtureId === fixture.fixture_id && + selected && + compatibility === "compatible", + ), + ).length; + const prerequisiteFailures = input.operation_outcomes.filter( + ({ call_kind: kind, expected_success: success }) => + !success && ["setup", "permission"].includes(kind), + ); + const localized = input.comparisons.filter( + ({ deliberate_divergence_ref: expected, divergence_refs: observed }) => + expected !== null && observed.includes(expected), + ).length; + const deliberate = input.comparisons.filter( + ({ deliberate_divergence_ref: expected }) => expected !== null, + ).length; + const falseEquivalence = input.comparisons.filter( + (comparison) => + comparison.verdict === "equivalent" && + (comparison.truncated || + comparison.unavailable_authority || + comparison.unstable || + comparison.contradiction_ids.length > 0), + ).length; + return { + first_valid_workflow_selection_rate: ratio( + selectedFixtures, + input.fixtures.length, + ), + malformed_or_over_limit_call_rate: ratio( + input.operation_outcomes.filter(({ call_kind: kind }) => + ["malformed", "over-limit"].includes(kind), + ).length, + input.operation_outcomes.length, + ), + incompatible_provider_invocations: input.workflow_candidates.filter( + ({ invoked, compatibility }) => invoked && compatibility !== "compatible", + ).length, + failed_calls_before_prerequisites: prerequisiteFailures.length, + recovery_rate: ratio( + prerequisiteFailures.filter(({ recovered }) => recovered).length, + prerequisiteFailures.length, + ), + divergence_localization_precision: ratio(localized, deliberate), + false_equivalence_count: falseEquivalence, + unowned_obligation_count: + input.obligation_ledger.reports.missing_owner_obligation_ids.length, + cleanup_leak_count: input.cleanup.filter( + ({ owned_resources_remaining: remaining }) => remaining > 0, + ).length, + nondeterministic_replay_count: input.replay.deterministic ? 0 : 1, + }; +}; + +const sourceValue = (input: ReconstructionReadinessInput): unknown => ({ + ...input, + replay: { ...input.replay, expected_source_digest: null }, +}); + +const ratio = (numerator: number, denominator: number): number => + denominator === 0 ? 1 : numerator / denominator; + +const digest = (value: unknown): string => { + const encoded = canonicalize(value); + if (encoded === undefined) + throw new TypeError("Reconstruction readiness value is not canonical JSON"); + return createHash("sha256").update(encoded).digest("hex"); +}; diff --git a/src/domain/reconstructionReadinessFindingHelpers.ts b/src/domain/reconstructionReadinessFindingHelpers.ts new file mode 100644 index 000000000..719008dbd --- /dev/null +++ b/src/domain/reconstructionReadinessFindingHelpers.ts @@ -0,0 +1,47 @@ +import type { + ReadinessFinding, + ReadinessStageId, +} from "./reconstructionReadinessSchemas.js"; + +export const addMissingEvidenceFindings = ( + findings: ReadinessFinding[], + stageId: ReadinessStageId, + referencedIds: readonly string[], + availableIds: ReadonlySet, +): void => { + for (const evidenceId of new Set(referencedIds)) + if (!availableIds.has(evidenceId)) + findings.push( + readinessFinding( + "referenced-evidence-missing", + stageId, + "unknown", + `Referenced Evidence is absent from the bundle: ${evidenceId}.`, + ), + ); +}; + +export const readinessFinding = ( + ...[code, stageId, status, detail, evidenceIds = []]: readonly [ + code: string, + stageId: ReadinessStageId, + status: ReadinessFinding["status"], + detail: string, + evidenceIds?: readonly string[], + ] +): ReadinessFinding => ({ + code, + stage_id: stageId, + status, + detail, + evidence_ids: [...new Set(evidenceIds)].sort(), +}); + +export const readinessFindingOrder = ( + left: ReadinessFinding, + right: ReadinessFinding, +): number => { + const leftKey = `${left.stage_id}:${left.code}:${left.detail}`; + const rightKey = `${right.stage_id}:${right.code}:${right.detail}`; + return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0; +}; diff --git a/src/domain/reconstructionReadinessIntegrityFindings.ts b/src/domain/reconstructionReadinessIntegrityFindings.ts new file mode 100644 index 000000000..843b48cb5 --- /dev/null +++ b/src/domain/reconstructionReadinessIntegrityFindings.ts @@ -0,0 +1,385 @@ +import { + addMissingEvidenceFindings, + readinessFinding as finding, +} from "./reconstructionReadinessFindingHelpers.js"; +import type { + ReadinessFinding, + ReconstructionReadinessInput, +} from "./reconstructionReadinessSchemas.js"; + +export const readinessIntegrityFindings = ( + input: ReconstructionReadinessInput, + evidenceIds: ReadonlySet, + sourceDigest: string, +): ReadinessFinding[] => [ + ...comparisonFindings(input, evidenceIds), + ...contradictionFindings(input, evidenceIds), + ...closureFindings(input, evidenceIds), + ...cleanupFindings(input, evidenceIds), + ...replayFindings(input, evidenceIds, sourceDigest), +]; + +const comparisonFindings = ( + input: ReconstructionReadinessInput, + evidenceIds: ReadonlySet, +): ReadinessFinding[] => { + const findings: ReadinessFinding[] = []; + for (const comparison of input.comparisons) { + const unsafeEquivalent = + comparison.verdict === "equivalent" && + (comparison.truncated || + comparison.unavailable_authority || + comparison.unstable || + comparison.contradiction_ids.length > 0); + if (unsafeEquivalent) + findings.push( + finding( + "false-equivalence", + "compare-authority-candidate", + "fail", + `${comparison.comparison_id} claims equivalence across incomplete or contradicted Evidence.`, + comparison.evidence_ids, + ), + ); + if ( + comparison.concurrent && + comparison.schedule_semantics === "total-order" + ) + findings.push( + finding( + "unsafe-concurrency-semantics", + "compare-authority-candidate", + "fail", + `${comparison.comparison_id} uses total-order semantics for concurrent behavior.`, + comparison.evidence_ids, + ), + ); + if ( + comparison.deliberate_divergence_ref !== null && + !comparison.divergence_refs.includes(comparison.deliberate_divergence_ref) + ) + findings.push( + finding( + "divergence-not-localized", + "compare-authority-candidate", + "fail", + `${comparison.comparison_id} did not localize its deliberate primary divergence.`, + comparison.evidence_ids, + ), + ); + addMissingEvidenceFindings( + findings, + "compare-authority-candidate", + comparison.evidence_ids, + evidenceIds, + ); + } + return findings; +}; + +const contradictionFindings = ( + input: ReconstructionReadinessInput, + evidenceIds: ReadonlySet, +): ReadinessFinding[] => { + const findings: ReadinessFinding[] = []; + const comparisons = new Map( + input.comparisons.map((comparison) => [ + comparison.comparison_id, + comparison, + ]), + ); + for (const contradiction of input.contradictions) { + if ( + contradiction.declared_sha256 === contradiction.observed_sha256 || + new Set(contradiction.evidence_ids).size < 2 + ) + findings.push( + finding( + "contradiction-proof-incomplete", + "preserve-contradictions", + "fail", + `${contradiction.contradiction_id} must preserve distinct hashes and Evidence records.`, + contradiction.evidence_ids, + ), + ); + for (const comparisonId of contradiction.affected_comparison_ids) { + const comparison = comparisons.get(comparisonId); + if (comparison === undefined) + findings.push( + finding( + "contradiction-comparison-missing", + "preserve-contradictions", + "unknown", + `Contradiction references missing comparison ${comparisonId}.`, + contradiction.evidence_ids, + ), + ); + else if ( + comparison.verdict === "equivalent" || + !comparison.contradiction_ids.includes(contradiction.contradiction_id) + ) + findings.push( + finding( + "contradiction-equivalence-not-blocked", + "preserve-contradictions", + "fail", + `${comparisonId} does not preserve contradiction ${contradiction.contradiction_id}.`, + contradiction.evidence_ids, + ), + ); + } + addMissingEvidenceFindings( + findings, + "preserve-contradictions", + contradiction.evidence_ids, + evidenceIds, + ); + } + return findings; +}; + +const closureFindings = ( + input: ReconstructionReadinessInput, + evidenceIds: ReadonlySet, +): ReadinessFinding[] => { + const findings: ReadinessFinding[] = []; + if (input.obligation_ledger.summary.required === 0) + findings.push( + finding( + "reconstruction-obligations-empty", + "verify-reconstruction-closure", + "unknown", + "A ready ledger must include at least one required reconstruction obligation.", + ), + ); + if ( + input.obligation_ledger.status !== "ready" || + input.obligation_ledger.summary.required_open > 0 + ) + findings.push( + finding( + "reconstruction-closure-open", + "verify-reconstruction-closure", + "unknown", + `Obligation ledger is ${input.obligation_ledger.status} with ${String(input.obligation_ledger.summary.required_open)} required obligations open.`, + input.obligation_ledger.evidence_links, + ), + ); + addMissingEvidenceFindings( + findings, + "verify-reconstruction-closure", + input.obligation_ledger.evidence_links, + evidenceIds, + ); + evaluateClosureHistory(input, findings, evidenceIds); + for (const check of input.delegation_checks) { + if (check.delegates_to_authority) + findings.push( + finding( + "candidate-delegates-to-authority", + "verify-reconstruction-closure", + "fail", + `${check.candidate_id} delegates behavior back to the authority.`, + check.evidence_ids, + ), + ); + addMissingEvidenceFindings( + findings, + "verify-reconstruction-closure", + check.evidence_ids, + evidenceIds, + ); + } + return findings; +}; + +const evaluateClosureHistory = ( + input: ReconstructionReadinessInput, + findings: ReadinessFinding[], + evidenceIds: ReadonlySet, +): void => { + const history = [...input.closure_history].sort( + (left, right) => left.sequence - right.sequence, + ); + if ( + new Set(history.map(({ sequence }) => sequence)).size !== history.length || + history.at(-1)?.ledger_digest !== input.obligation_ledger.closure_digest + ) + findings.push( + finding( + "closure-history-identity-mismatch", + "verify-reconstruction-closure", + "fail", + "Closure history sequences must be unique and end at the supplied ledger digest.", + ), + ); + if ( + history[0]?.required_open === 0 || + history.at(-1)?.required_open !== 0 || + history.at(-1)?.status !== "ready" + ) + findings.push( + finding( + "incremental-closure-history-missing", + "verify-reconstruction-closure", + "unknown", + "Closure history must begin partial and end ready with zero required obligations open.", + ), + ); + for (const [index, observation] of history.entries()) { + const previous = history[index - 1]; + if ( + previous !== undefined && + (observation.required_open > previous.required_open || + observation.newly_verified_obligation_ids.length !== + previous.required_open - observation.required_open) + ) + findings.push( + finding( + "invalid-obligation-closure-transition", + "verify-reconstruction-closure", + "fail", + `Closure observation ${String(observation.sequence)} does not match its newly verified obligations.`, + observation.evidence_ids, + ), + ); + if ( + observation.newly_verified_obligation_ids.some( + (obligationId) => + !input.obligation_ledger.obligations.some( + ({ obligation_id: candidateId, status }) => + candidateId === obligationId && status === "verified", + ), + ) + ) + findings.push( + finding( + "closure-obligation-proof-missing", + "verify-reconstruction-closure", + "fail", + `Closure observation ${String(observation.sequence)} names an obligation without verified ledger proof.`, + observation.evidence_ids, + ), + ); + addMissingEvidenceFindings( + findings, + "verify-reconstruction-closure", + observation.evidence_ids, + evidenceIds, + ); + } +}; + +const cleanupFindings = ( + input: ReconstructionReadinessInput, + evidenceIds: ReadonlySet, +): ReadinessFinding[] => { + const findings: ReadinessFinding[] = []; + for (const cleanup of input.cleanup) { + if (cleanup.owned_resources_remaining > 0) + findings.push( + finding( + "owned-resource-leak", + "reactive-scenarios", + "fail", + `${cleanup.run_id} retained ${String(cleanup.owned_resources_remaining)} owned resources.`, + cleanup.diagnostic_evidence_ids, + ), + ); + if (cleanup.cancelled && cleanup.diagnostic_evidence_ids.length === 0) + findings.push( + finding( + "cancelled-diagnostic-evidence-missing", + "reactive-scenarios", + "unknown", + `${cleanup.run_id} cancellation retained no diagnostic Evidence.`, + ), + ); + addMissingEvidenceFindings( + findings, + "reactive-scenarios", + cleanup.diagnostic_evidence_ids, + evidenceIds, + ); + } + for (const grant of input.grants) { + if ( + ["denied", "cancelled"].includes(grant.decision) && + grant.launched_process + ) + findings.push( + finding( + "denied-authority-launched-process", + "acquire-authority", + "fail", + `${grant.grant_id} launched a process after ${grant.decision}.`, + grant.evidence_ids, + ), + ); + addMissingEvidenceFindings( + findings, + "acquire-authority", + grant.evidence_ids, + evidenceIds, + ); + } + return findings; +}; + +const replayFindings = ( + input: ReconstructionReadinessInput, + evidenceIds: ReadonlySet, + sourceDigest: string, +): ReadinessFinding[] => { + const findings: ReadinessFinding[] = []; + if (!input.replay.deterministic) + findings.push( + finding( + "replay-nondeterministic", + "export-replay", + "fail", + "Clean replay did not reproduce the same semantic report.", + input.replay.evidence_ids, + ), + ); + if (!input.replay.tamper_detected) + findings.push( + finding( + "tamper-not-detected", + "export-replay", + "fail", + "The conformance replay did not reject a tampered input.", + input.replay.evidence_ids, + ), + ); + if (!input.replay.stale_input_detected) + findings.push( + finding( + "stale-input-not-detected", + "export-replay", + "fail", + "The conformance replay did not reject a stale digest.", + input.replay.evidence_ids, + ), + ); + if ( + input.replay.expected_source_digest !== null && + input.replay.expected_source_digest !== sourceDigest + ) + findings.push( + finding( + "replay-source-digest-mismatch", + "export-replay", + "fail", + `Expected ${input.replay.expected_source_digest}; observed ${sourceDigest}.`, + input.replay.evidence_ids, + ), + ); + addMissingEvidenceFindings( + findings, + "export-replay", + input.replay.evidence_ids, + evidenceIds, + ); + return findings; +}; diff --git a/src/domain/reconstructionReadinessJourneyFindings.ts b/src/domain/reconstructionReadinessJourneyFindings.ts new file mode 100644 index 000000000..2acde376e --- /dev/null +++ b/src/domain/reconstructionReadinessJourneyFindings.ts @@ -0,0 +1,401 @@ +import { + addMissingEvidenceFindings, + readinessFinding as finding, +} from "./reconstructionReadinessFindingHelpers.js"; +import { + readinessStageIdSchema, + type ReadinessFinding, + type ReadinessStageId, + type ReconstructionReadinessInput, +} from "./reconstructionReadinessSchemas.js"; + +export const READINESS_REQUIRED_CHECKS: Readonly< + Record +> = { + "discover-classify": [ + "target-free-start", + "artifact-inventory", + "compatible-workflow-selected", + "limits-visible", + ], + "diagnose-environment": [ + "broken-runtime-distinguished", + "version-skew-detected", + "revision-changed-after-recovery", + ], + "acquire-authority": [ + "missing-scope-reported", + "narrow-grant-resumed", + "expansion-reprompted", + "denial-launched-nothing", + ], + "static-analysis": [ + "native-routed", + "javascript-routed", + "unknowns-preserved", + ], + "reactive-scenarios": [ + "predicate-driven", + "descendant-settled", + "electron-correlated", + "cancel-cleanup-preserved-evidence", + ], + "compare-authority-candidate": [ + "partial-order-accepted", + "divergence-localized", + "incomplete-is-unknown", + ], + "preserve-contradictions": [ + "record-and-continue", + "affected-equivalence-blocked", + "unaffected-comparison-continued", + ], + "verify-reconstruction-closure": [ + "incomplete-detected", + "incremental-verifier-closure", + "delegation-rejected", + ], + "export-replay": [ + "cli-mcp-parity", + "failure-status-parity", + "deterministic-replay", + "tamper-detected", + "stale-input-detected", + ], +}; + +export const READINESS_STAGE_IDS = readinessStageIdSchema.options; + +export const readinessJourneyFindings = ( + input: ReconstructionReadinessInput, + evidenceIds: ReadonlySet, +): ReadinessFinding[] => [ + ...identityFindings(input), + ...fixtureFindings(input), + ...stageFindings(input, evidenceIds), + ...capabilityFindings(input), + ...workflowFindings(input), + ...authorityFindings(input), + ...outcomeFindings(input, evidenceIds), +]; + +const identityFindings = ( + input: ReconstructionReadinessInput, +): ReadinessFinding[] => { + const findings: ReadinessFinding[] = []; + if (!input.identity.versions.some(({ state }) => state === "stale")) + findings.push( + finding( + "version-skew-observation-missing", + "diagnose-environment", + "fail", + "The journey did not preserve a stale version observation.", + ), + ); + if (!input.identity.versions.some(({ state }) => state === "current")) + findings.push( + finding( + "corrected-version-observation-missing", + "diagnose-environment", + "fail", + "The journey did not preserve corrected current version state.", + ), + ); + if (!input.identity.providers.some(({ state }) => state === "broken-host")) + findings.push( + finding( + "broken-host-observation-missing", + "diagnose-environment", + "fail", + "The journey did not distinguish a broken host dependency.", + ), + ); + return findings; +}; + +const fixtureFindings = ( + input: ReconstructionReadinessInput, +): ReadinessFinding[] => { + const requiredKinds = [ + "native", + "javascript-cli", + "electron", + "incomplete-reconstruction", + "broken-runtime", + ] as const; + return requiredKinds.flatMap((kind) => + input.fixtures.some(({ kind: fixtureKind }) => fixtureKind === kind) + ? [] + : [ + finding( + "representative-fixture-missing", + "discover-classify", + "fail", + `The required ${kind} fixture is missing.`, + ), + ], + ); +}; + +const stageFindings = ( + input: ReconstructionReadinessInput, + evidenceIds: ReadonlySet, +): ReadinessFinding[] => { + const findings: ReadinessFinding[] = []; + for (const stageId of READINESS_STAGE_IDS) { + const matching = input.stages.filter(({ stage_id: id }) => id === stageId); + if (matching.length !== 1) { + findings.push( + finding( + "stage-cardinality", + stageId, + "fail", + `Expected exactly one ${stageId} stage; observed ${String(matching.length)}.`, + ), + ); + continue; + } + const stage = matching[0]; + if (stage === undefined) continue; + if (!stage.required) + findings.push( + finding( + "required-stage-disabled", + stageId, + "fail", + "The public conformance journey requires this stage.", + ), + ); + const checks = new Map( + stage.checks.map((check) => [check.check_id, check]), + ); + if (checks.size !== stage.checks.length) + findings.push( + finding( + "duplicate-stage-check", + stageId, + "fail", + "Stage check IDs must be unique.", + ), + ); + for (const checkId of READINESS_REQUIRED_CHECKS[stageId]) { + const check = checks.get(checkId); + if (check === undefined) { + findings.push( + finding( + "missing-stage-check", + stageId, + "fail", + `Required conformance check is missing: ${checkId}.`, + ), + ); + continue; + } + if (check.status !== "pass") + findings.push( + finding( + "stage-check-not-pass", + stageId, + check.status === "fail" ? "fail" : "unknown", + `${checkId} is ${check.status}: ${check.detail}`, + check.evidence_ids, + ), + ); + if (check.evidence_ids.length === 0) + findings.push( + finding( + "stage-check-evidence-missing", + stageId, + "unknown", + `${checkId} has no attributable Evidence.`, + ), + ); + addMissingEvidenceFindings( + findings, + stageId, + [...stage.evidence_ids, ...check.evidence_ids], + evidenceIds, + ); + } + } + return findings; +}; + +const capabilityFindings = ( + input: ReconstructionReadinessInput, +): ReadinessFinding[] => + input.capabilities.flatMap((capability) => { + const findings: ReadinessFinding[] = []; + if (capability.bounded && capability.limits.length === 0) + findings.push( + finding( + "bounded-limit-hidden", + "discover-classify", + "fail", + `${capability.capability_id} is bounded but declares no exact limits.`, + ), + ); + if (!capability.available && capability.unavailable_reason === null) + findings.push( + finding( + "unavailable-reason-missing", + "discover-classify", + "unknown", + `${capability.capability_id} is unavailable without an actionable reason.`, + ), + ); + return findings; + }); + +const workflowFindings = ( + input: ReconstructionReadinessInput, +): ReadinessFinding[] => { + const findings: ReadinessFinding[] = []; + for (const fixture of input.fixtures) { + const selected = input.workflow_candidates.filter( + ({ fixture_id: fixtureId, selected: isSelected }) => + fixtureId === fixture.fixture_id && isSelected, + ); + if (selected.length !== 1 || selected[0]?.compatibility !== "compatible") + findings.push( + finding( + "compatible-workflow-not-selected", + "discover-classify", + "fail", + `${fixture.fixture_id} does not have exactly one compatible selected workflow.`, + ), + ); + } + for (const candidate of input.workflow_candidates) + if (candidate.invoked && candidate.compatibility !== "compatible") + findings.push( + finding( + "incompatible-provider-invoked", + "static-analysis", + "fail", + `${candidate.workflow_id} was invoked despite ${candidate.compatibility} routing.`, + ), + ); + return findings; +}; + +const authorityFindings = ( + input: ReconstructionReadinessInput, +): ReadinessFinding[] => { + const deniedScopes = new Set( + input.grants + .filter(({ decision }) => ["denied", "cancelled"].includes(decision)) + .map(({ scope }) => scope), + ); + const recoveredScopes = new Set( + input.grants + .filter(({ decision }) => decision === "granted") + .map(({ scope }) => scope), + ); + return [...deniedScopes].some((scope) => recoveredScopes.has(scope)) + ? [] + : [ + finding( + "narrow-authority-recovery-missing", + "acquire-authority", + "fail", + "No denied or cancelled scope was later recovered by a narrow grant.", + ), + ]; +}; + +const outcomeFindings = ( + input: ReconstructionReadinessInput, + evidenceIds: ReadonlySet, +): ReadinessFinding[] => { + const findings: ReadinessFinding[] = []; + const parity = new Map>(); + for (const outcome of input.operation_outcomes) { + const expectedCliExit = outcome.expected_success ? 0 : 1; + if ( + outcome.surface === "cli" && + (outcome.cli_exit_code === null || + (expectedCliExit === 0 + ? outcome.cli_exit_code !== 0 + : outcome.cli_exit_code === 0)) + ) + findings.push( + finding( + "cli-status-mismatch", + "export-replay", + "fail", + `${outcome.operation} CLI process status contradicts its structured outcome.`, + outcome.evidence_ids, + ), + ); + if ( + outcome.surface === "mcp" && + outcome.mcp_status !== + (outcome.expected_success ? "success" : "non-success") + ) + findings.push( + finding( + "mcp-status-mismatch", + "export-replay", + "fail", + `${outcome.operation} MCP status contradicts its structured outcome.`, + outcome.evidence_ids, + ), + ); + const key = `${outcome.operation}:${outcome.call_kind}:${String(outcome.expected_success)}`; + parity.set(key, new Set([...(parity.get(key) ?? []), outcome.surface])); + addMissingEvidenceFindings( + findings, + "export-replay", + outcome.evidence_ids, + evidenceIds, + ); + } + for (const [key, surfaces] of parity) + if (surfaces.size !== 2) + findings.push( + finding( + "surface-parity-missing", + "export-replay", + "fail", + `CLI/MCP outcome pair is incomplete: ${key}.`, + ), + ); + for (const [key, outcomes] of groupedOutcomes(input)) { + const cli = outcomes.find(({ surface }) => surface === "cli"); + const mcp = outcomes.find(({ surface }) => surface === "mcp"); + if ( + cli !== undefined && + mcp !== undefined && + (cli.expected_success !== mcp.expected_success || + cli.error_code !== mcp.error_code) + ) + findings.push( + finding( + "surface-semantic-drift", + "export-replay", + "fail", + `CLI/MCP structured outcomes disagree: ${key}.`, + [...cli.evidence_ids, ...mcp.evidence_ids], + ), + ); + } + return findings; +}; + +const groupedOutcomes = ( + input: ReconstructionReadinessInput, +): ReadonlyMap< + string, + readonly ReconstructionReadinessInput["operation_outcomes"][number][] +> => { + const grouped = new Map< + string, + ReconstructionReadinessInput["operation_outcomes"] + >(); + for (const outcome of input.operation_outcomes) { + const key = `${outcome.operation}:${outcome.call_kind}`; + grouped.set(key, [...(grouped.get(key) ?? []), outcome]); + } + return grouped; +}; diff --git a/src/domain/reconstructionReadinessSchemas.ts b/src/domain/reconstructionReadinessSchemas.ts new file mode 100644 index 000000000..8941afacd --- /dev/null +++ b/src/domain/reconstructionReadinessSchemas.ts @@ -0,0 +1,272 @@ +import { z } from "zod"; + +import { evidenceBundleSchema } from "./evidenceBundle.js"; +import { reconstructionObligationLedgerSchema } from "./reconstructionObligationLedgerSchemas.js"; + +const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u); +const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u); +const stableIdSchema = z.string().regex(/^[A-Za-z][A-Za-z0-9._:/-]{0,199}$/u); +const boundedTextSchema = z.string().trim().min(1).max(4_096); + +export const readinessStatusSchema = z.enum([ + "pass", + "fail", + "unknown", + "unsupported", + "truncated", + "skipped", +]); + +export const readinessStageIdSchema = z.enum([ + "discover-classify", + "diagnose-environment", + "acquire-authority", + "static-analysis", + "reactive-scenarios", + "compare-authority-candidate", + "preserve-contradictions", + "verify-reconstruction-closure", + "export-replay", +]); + +const versionObservationSchema = z.strictObject({ + component: stableIdSchema, + version: boundedTextSchema.nullable(), + digest: digestSchema.nullable(), + state: z.enum(["current", "stale", "unavailable"]), + remediation: boundedTextSchema.nullable(), +}); + +const providerIdentitySchema = z.strictObject({ + provider_id: stableIdSchema, + version: boundedTextSchema.nullable(), + digest: digestSchema.nullable(), + state: z.enum(["available", "unavailable", "incompatible", "broken-host"]), + reason_code: stableIdSchema.nullable(), +}); + +const limitSchema = z.strictObject({ + name: stableIdSchema, + value: z.number().finite().nonnegative(), + unit: z.enum(["count", "bytes", "milliseconds", "ratio"]), +}); + +const capabilitySchema = z.strictObject({ + capability_id: stableIdSchema, + available: z.boolean(), + bounded: z.boolean(), + side_effect: z.enum([ + "none", + "reads-target", + "launches-process", + "writes-workspace", + "network-observation", + ]), + authority_scopes: z.array(stableIdSchema).max(100), + limits: z.array(limitSchema).max(100), + unavailable_reason: boundedTextSchema.nullable(), +}); + +const fixtureSchema = z.strictObject({ + fixture_id: stableIdSchema, + kind: z.enum([ + "native", + "javascript-cli", + "electron", + "incomplete-reconstruction", + "broken-runtime", + ]), + artifact_sha256: digestSchema, + environment_id: stableIdSchema, + deliberate_faults: z.array(stableIdSchema).min(1).max(100), +}); + +const workflowCandidateSchema = z.strictObject({ + fixture_id: stableIdSchema, + workflow_id: stableIdSchema, + provider_id: stableIdSchema.nullable(), + selected: z.boolean(), + invoked: z.boolean(), + compatibility: z.enum(["compatible", "incompatible", "unavailable"]), + reason_code: stableIdSchema, +}); + +const grantSchema = z.strictObject({ + grant_id: stableIdSchema, + scope: stableIdSchema, + decision: z.enum(["granted", "denied", "cancelled", "restart-required"]), + launched_process: z.boolean(), + evidence_ids: z.array(evidenceIdSchema).max(100), +}); + +const comparisonSchema = z.strictObject({ + comparison_id: stableIdSchema, + verdict: z.enum(["equivalent", "different", "unknown"]), + schedule_semantics: z.enum(["total-order", "partial-order", "finite-traces"]), + concurrent: z.boolean(), + truncated: z.boolean(), + unavailable_authority: z.boolean(), + unstable: z.boolean(), + deliberate_divergence_ref: boundedTextSchema.nullable(), + divergence_refs: z.array(boundedTextSchema).max(100), + contradiction_ids: z.array(stableIdSchema).max(100), + evidence_ids: z.array(evidenceIdSchema).min(1).max(1_000), +}); + +const contradictionSchema = z.strictObject({ + contradiction_id: stableIdSchema, + declared_sha256: digestSchema, + observed_sha256: digestSchema, + policy: z.literal("record-and-continue"), + affected_comparison_ids: z.array(stableIdSchema).min(1).max(100), + evidence_ids: z.array(evidenceIdSchema).min(2).max(100), +}); + +const operationOutcomeSchema = z.strictObject({ + sequence: z.number().int().min(0), + operation: stableIdSchema, + surface: z.enum(["cli", "mcp"]), + call_kind: z.enum([ + "valid", + "malformed", + "over-limit", + "incompatible-provider", + "setup", + "permission", + "operation", + ]), + expected_success: z.boolean(), + cli_exit_code: z.number().int().min(0).nullable(), + mcp_status: z.enum(["success", "non-success"]).nullable(), + error_code: stableIdSchema.nullable(), + recovered: z.boolean(), + evidence_ids: z.array(evidenceIdSchema).max(100), +}); + +const cleanupSchema = z.strictObject({ + run_id: stableIdSchema, + cancelled: z.boolean(), + owned_resources_remaining: z.number().int().min(0), + diagnostic_evidence_ids: z.array(evidenceIdSchema).max(100), +}); + +const closureObservationSchema = z.strictObject({ + sequence: z.number().int().min(0), + ledger_digest: digestSchema, + status: z.enum(["ready", "open", "failed", "unknown"]), + required_open: z.number().int().min(0), + newly_verified_obligation_ids: z.array(stableIdSchema).max(10_000), + evidence_ids: z.array(evidenceIdSchema).min(1).max(1_000), +}); + +const delegationCheckSchema = z.strictObject({ + candidate_id: stableIdSchema, + delegates_to_authority: z.boolean(), + evidence_ids: z.array(evidenceIdSchema).min(1).max(100), +}); + +const stageCheckSchema = z.strictObject({ + check_id: stableIdSchema, + status: readinessStatusSchema, + detail: boundedTextSchema, + evidence_ids: z.array(evidenceIdSchema).max(100), +}); + +const stageSchema = z.strictObject({ + stage_id: readinessStageIdSchema, + required: z.boolean(), + status: readinessStatusSchema, + capability_issue: stableIdSchema.nullable(), + next_action: boundedTextSchema.nullable(), + evidence_ids: z.array(evidenceIdSchema).max(1_000), + checks: z.array(stageCheckSchema).max(100), +}); + +const replaySchema = z.strictObject({ + expected_source_digest: digestSchema.nullable(), + deterministic: z.boolean(), + tamper_detected: z.boolean(), + stale_input_detected: z.boolean(), + evidence_ids: z.array(evidenceIdSchema).max(100), +}); + +export const reconstructionReadinessInputSchema = z.strictObject({ + schema_version: z.literal(1), + identity: z.strictObject({ + cli_version: boundedTextSchema, + server_version: boundedTextSchema, + catalog_digest: digestSchema, + skill_digest: digestSchema.nullable(), + versions: z.array(versionObservationSchema).min(1).max(100), + providers: z.array(providerIdentitySchema).max(100), + }), + client: z.strictObject({ + name: boundedTextSchema, + version: boundedTextSchema, + negotiated_capabilities: z.array(stableIdSchema).max(100), + }), + capabilities: z.array(capabilitySchema).min(1).max(1_000), + fixtures: z.array(fixtureSchema).min(1).max(100), + workflow_candidates: z.array(workflowCandidateSchema).min(1).max(1_000), + grants: z.array(grantSchema).max(1_000), + evidence_bundle: evidenceBundleSchema, + comparisons: z.array(comparisonSchema).max(1_000), + contradictions: z.array(contradictionSchema).max(1_000), + obligation_ledger: reconstructionObligationLedgerSchema, + operation_outcomes: z.array(operationOutcomeSchema).max(10_000), + cleanup: z.array(cleanupSchema).max(1_000), + closure_history: z.array(closureObservationSchema).min(2).max(1_000), + delegation_checks: z.array(delegationCheckSchema).min(1).max(1_000), + replay: replaySchema, + stages: z.array(stageSchema).max(20), +}); + +const findingSchema = z.strictObject({ + code: stableIdSchema, + stage_id: readinessStageIdSchema, + status: z.enum(["fail", "unknown"]), + detail: boundedTextSchema, + evidence_ids: z.array(evidenceIdSchema).max(100), +}); + +export const reconstructionReadinessReportSchema = z.strictObject({ + schema: z.literal("ReconstructionReadinessReport"), + schema_version: z.literal(1), + report_id: z.string().regex(/^rr_[a-f0-9]{64}$/u), + source_digest: digestSchema, + report_digest: digestSchema, + status: readinessStatusSchema, + summary: z.strictObject({ + stages: z.number().int().min(0), + required_stages: z.number().int().min(0), + passed_required_stages: z.number().int().min(0), + findings: z.number().int().min(0), + failed_findings: z.number().int().min(0), + unknown_findings: z.number().int().min(0), + }), + metrics: z.strictObject({ + first_valid_workflow_selection_rate: z.number().min(0).max(1), + malformed_or_over_limit_call_rate: z.number().min(0).max(1), + incompatible_provider_invocations: z.number().int().min(0), + failed_calls_before_prerequisites: z.number().int().min(0), + recovery_rate: z.number().min(0).max(1), + divergence_localization_precision: z.number().min(0).max(1), + false_equivalence_count: z.number().int().min(0), + unowned_obligation_count: z.number().int().min(0), + cleanup_leak_count: z.number().int().min(0), + nondeterministic_replay_count: z.number().int().min(0), + }), + stages: z.array(stageSchema).max(20), + findings: z.array(findingSchema).max(10_000), + evidence_links: z.array(evidenceIdSchema).max(100_000), + snapshot: reconstructionReadinessInputSchema, +}); + +export type ReconstructionReadinessInput = z.infer< + typeof reconstructionReadinessInputSchema +>; +export type ReconstructionReadinessReport = z.infer< + typeof reconstructionReadinessReportSchema +>; +export type ReadinessStageId = z.infer; +export type ReadinessFinding = z.infer; diff --git a/src/domain/sourceToBundleComparison.ts b/src/domain/sourceToBundleComparison.ts new file mode 100644 index 000000000..64155fccd --- /dev/null +++ b/src/domain/sourceToBundleComparison.ts @@ -0,0 +1,482 @@ +import { createHash } from "node:crypto"; + +import canonicalize from "canonicalize"; + +import type { JavaScriptApplicationGraph } from "./javascriptApplicationGraph.js"; +import type { HistoricalSourceGraph } from "./referenceSourceGraph.js"; +import { + SOURCE_TO_BUNDLE_SIGNAL_WEIGHTS, + sourceToBundleComparisonResultSchema, + type CompareSourceToBundleInput, + type SourceToBundleCandidate, + type SourceToBundleComparisonItem, + type SourceToBundleComparisonResult, +} from "./sourceToBundleComparisonSchemas.js"; +import { + buildSourceToBundleCandidateIndex, + candidateIdsForSource, + historicalSourceFiles, + scoreSourceToBundleCandidate, + sourceBearingNodes, + type SourceToBundleCandidateIndex, +} from "./sourceToBundleSignals.js"; + +interface SourceToBundleComparisonInput { + readonly reference: HistoricalSourceGraph; + readonly application: { + readonly evidenceId: string; + readonly rootArtifactSha256: string; + readonly graph: JavaScriptApplicationGraph; + }; + readonly limits: CompareSourceToBundleInput["limits"]; +} + +interface ClassifiedMapping { + readonly status: SourceToBundleComparisonItem["status"]; + readonly confidence: SourceToBundleComparisonItem["confidence"]; + readonly currentNodeIds: string[]; + readonly limitations: string[]; +} + +interface ComparisonProjection { + readonly sources: ReturnType; + readonly currentNodes: ReturnType; + readonly index: SourceToBundleCandidateIndex; + readonly absenceComplete: boolean; + readonly factors: CoverageFactors; +} + +interface MappingProjection { + readonly items: SourceToBundleComparisonItem[]; + readonly candidateEvaluations: number; + readonly omittedCandidateEvaluations: number; + readonly omittedCandidateReferences: number; +} + +/** Compare historical source files to static source-bearing application nodes. */ +export const compareSourceToBundle = ( + input: SourceToBundleComparisonInput, +): SourceToBundleComparisonResult => { + const projection = projectComparison(input); + const mappings = compareMappings(input, projection); + const factors = { + ...projection.factors, + omittedCandidateEvaluations: mappings.omittedCandidateEvaluations, + omittedCandidateReferences: mappings.omittedCandidateReferences, + }; + const mappedCurrentIds = new Set( + mappings.items.flatMap(({ current_node_ids: nodeIds }) => nodeIds), + ); + const unmappedCurrentNodeIds = projection.currentNodes + .map(({ node_id: nodeId }) => nodeId) + .filter((nodeId) => !mappedCurrentIds.has(nodeId)); + const semanticResult = { + schema_version: 1 as const, + reference: { + root_sha256: input.reference.root_sha256, + inventory_state: input.reference.inventory_state, + }, + application: { + evidence_id: input.application.evidenceId, + graph_id: input.application.graph.graph_id, + root_artifact_sha256: input.application.rootArtifactSha256, + }, + scoring: scoringModel(), + summary: comparisonSummary(mappings.items), + items: mappings.items, + unmapped_current_node_ids: unmappedCurrentNodeIds, + coverage: comparisonCoverage(projection, mappings, factors), + evidence_links: [input.application.evidenceId], + limitations: comparisonLimitations(factors), + }; + return sourceToBundleComparisonResultSchema.parse({ + ...semanticResult, + comparison_id: `stbc_${digestCanonical(semanticResult)}`, + }); +}; + +const projectComparison = ( + input: SourceToBundleComparisonInput, +): ComparisonProjection => { + const allSources = historicalSourceFiles(input.reference); + const allCurrentNodes = sourceBearingNodes(input.application.graph.nodes); + const sources = allSources.slice(0, input.limits.max_source_files); + const currentNodes = allCurrentNodes.slice( + 0, + input.limits.max_application_nodes, + ); + const omittedSourceFiles = allSources.length - sources.length; + const omittedApplicationNodes = allCurrentNodes.length - currentNodes.length; + const index = buildSourceToBundleCandidateIndex(currentNodes); + return { + sources, + currentNodes, + index, + absenceComplete: + input.reference.inventory_state === "complete" && + input.application.graph.coverage.status === "complete" && + !input.application.graph.coverage.truncated && + omittedSourceFiles === 0 && + omittedApplicationNodes === 0 && + !index.pathIndexTruncated, + factors: { + input, + omittedSourceFiles, + omittedApplicationNodes, + omittedCandidateEvaluations: 0, + omittedCandidateReferences: 0, + pathIndexTruncated: index.pathIndexTruncated, + }, + }; +}; + +const compareMappings = ( + input: SourceToBundleComparisonInput, + projection: ComparisonProjection, +): MappingProjection => { + let evaluationsRemaining = input.limits.max_candidate_evaluations; + let candidateEvaluations = 0; + let omittedCandidateEvaluations = 0; + let omittedCandidateReferences = 0; + const provisionalItems: SourceToBundleComparisonItem[] = []; + for (const source of projection.sources) { + const candidateIds = candidateIdsForSource(source, projection.index); + const retainedIds = candidateIds.slice(0, evaluationsRemaining); + const omittedEvaluations = candidateIds.length - retainedIds.length; + evaluationsRemaining -= retainedIds.length; + candidateEvaluations += retainedIds.length; + omittedCandidateEvaluations += omittedEvaluations; + const scored = retainedIds + .map((nodeId) => + scoreSourceToBundleCandidate(source, nodeId, projection.index), + ) + .filter( + (candidate): candidate is SourceToBundleCandidate => candidate !== null, + ) + .sort(compareCandidates); + const retainedCandidates = scored.slice( + 0, + input.limits.max_candidate_nodes, + ); + const omittedCandidates = + omittedEvaluations + scored.length - retainedCandidates.length; + omittedCandidateReferences += omittedCandidates; + const classification = classifyMapping( + retainedCandidates, + omittedCandidates === 0, + projection.absenceComplete, + ); + const semantic = { + source_path: source.path, + source_sha256: source.sha256, + source_language: source.language, + status: classification.status, + confidence: classification.confidence, + current_node_ids: classification.currentNodeIds, + candidates: retainedCandidates, + omitted_candidates: omittedCandidates, + limitations: uniqueSorted([ + ...source.limitations, + ...classification.limitations, + ...(omittedCandidates > 0 + ? [ + `${String(omittedCandidates)} candidate references were omitted by comparison limits.`, + ] + : []), + ]), + }; + provisionalItems.push({ + mapping_id: mappingId(input, semantic), + ...semantic, + }); + } + return { + items: markMergedMappings(input, provisionalItems), + candidateEvaluations, + omittedCandidateEvaluations, + omittedCandidateReferences, + }; +}; + +const comparisonCoverage = ( + projection: ComparisonProjection, + mappings: MappingProjection, + factors: CoverageFactors, +): SourceToBundleComparisonResult["coverage"] => ({ + status: coverageStatus(factors), + reference_inventory_state: factors.input.reference.inventory_state, + application_graph_status: factors.input.application.graph.coverage.status, + retained_source_files: projection.sources.length, + omitted_source_files: factors.omittedSourceFiles, + retained_application_nodes: projection.currentNodes.length, + omitted_application_nodes: factors.omittedApplicationNodes, + candidate_evaluations: mappings.candidateEvaluations, + omitted_candidate_evaluations: mappings.omittedCandidateEvaluations, + omitted_candidate_references: mappings.omittedCandidateReferences, + omitted_unmapped_current_nodes: 0, +}); + +const scoringModel = (): SourceToBundleComparisonResult["scoring"] => ({ + algorithm: "rea-source-to-bundle-signals/v1", + minimum_candidate_score: 20, + weights: Object.entries(SOURCE_TO_BUNDLE_SIGNAL_WEIGHTS).map( + ([signal, weight]) => ({ + signal: signal as keyof typeof SOURCE_TO_BUNDLE_SIGNAL_WEIGHTS, + weight, + }), + ), +}); + +const classifyMapping = ( + candidates: readonly SourceToBundleCandidate[], + evaluationComplete: boolean, + absenceComplete: boolean, +): ClassifiedMapping => { + if (!evaluationComplete) + return { + status: "unknown", + confidence: "unknown", + currentNodeIds: [], + limitations: [ + "Candidate evaluation stopped at the declared limit; a stronger match may be omitted.", + ], + }; + const exact = candidates.filter((candidate) => + hasSignal(candidate, "exact-source-digest"), + ); + if (exact.length === 1) + return { + status: "unchanged", + confidence: "exact", + currentNodeIds: [exact[0]?.current_node_id ?? ""].filter(Boolean), + limitations: [ + "Exact byte identity does not establish equivalent load context or runtime behavior.", + ], + }; + if (exact.length > 1) + return { + status: "duplicated", + confidence: "exact", + currentNodeIds: exact.map(({ current_node_id: nodeId }) => nodeId), + limitations: [ + "The same historical source digest occurs in multiple current graph nodes.", + ], + }; + const pathCandidates = candidates.filter((candidate) => + candidate.signals.some(({ kind }) => + [ + "source-map-original-path", + "current-path-exact", + "current-path-suffix", + ].includes(kind), + ), + ); + const topScore = pathCandidates[0]?.score; + const topPathCandidates = + topScore === undefined + ? [] + : pathCandidates.filter(({ score }) => score === topScore); + if (topPathCandidates.length === 1) { + const candidate = topPathCandidates[0]; + return { + status: "unknown", + confidence: "unknown", + currentNodeIds: + candidate === undefined ? [] : [candidate.current_node_id], + limitations: [ + "Location evidence links the source to the current node, but comparable source digests are unavailable.", + ], + }; + } + if (topPathCandidates.length > 1) + return { + status: "split", + confidence: "medium", + currentNodeIds: topPathCandidates.map( + ({ current_node_id: nodeId }) => nodeId, + ), + limitations: [ + "Multiple equally scored current nodes retain the historical source path; this is a static split inference, not runtime proof.", + ], + }; + if (candidates.length > 0) + return { + status: "unknown", + confidence: "unknown", + currentNodeIds: [], + limitations: [ + "Only weak basename or extension signals were available; no mapping was forced.", + ], + }; + return absenceComplete + ? { + status: "removed", + confidence: "high", + currentNodeIds: [], + limitations: [ + "No retained current node matched exact digest or path signals within complete static inventories.", + ], + } + : { + status: "unknown", + confidence: "unknown", + currentNodeIds: [], + limitations: [ + "No candidate was observed, but incomplete input or graph coverage prevents a removal claim.", + ], + }; +}; + +const markMergedMappings = ( + input: SourceToBundleComparisonInput, + items: readonly SourceToBundleComparisonItem[], +): SourceToBundleComparisonItem[] => { + const sourcesByNode = new Map(); + for (const item of items) { + if (item.status !== "unchanged" || item.current_node_ids.length !== 1) + continue; + const nodeId = item.current_node_ids[0]; + if (nodeId === undefined) continue; + sourcesByNode.set(nodeId, [...(sourcesByNode.get(nodeId) ?? []), item]); + } + const mergedSources = new Set( + [...sourcesByNode.values()] + .filter((group) => group.length > 1) + .flatMap((group) => group.map(({ source_path: path }) => path)), + ); + return items.map((item) => { + if (!mergedSources.has(item.source_path)) return item; + const { mapping_id: previousMappingId, ...base } = item; + void previousMappingId; + const semantic = { + ...base, + status: "merged" as const, + confidence: "exact" as const, + limitations: uniqueSorted([ + ...item.limitations, + "Multiple historical source paths have exact digest identity with the same current graph node.", + ]), + }; + return { ...semantic, mapping_id: mappingId(input, semantic) }; + }); +}; + +const mappingId = ( + input: SourceToBundleComparisonInput, + item: Omit, +): string => + `stbc_item_${digestCanonical({ + reference_root_sha256: input.reference.root_sha256, + application_graph_id: input.application.graph.graph_id, + ...item, + })}`; + +const comparisonSummary = ( + items: readonly SourceToBundleComparisonItem[], +): SourceToBundleComparisonResult["summary"] => ({ + unchanged: countStatus(items, "unchanged"), + modified: countStatus(items, "modified"), + removed: countStatus(items, "removed"), + split: countStatus(items, "split"), + merged: countStatus(items, "merged"), + duplicated: countStatus(items, "duplicated"), + unknown: countStatus(items, "unknown"), +}); + +const countStatus = ( + items: readonly SourceToBundleComparisonItem[], + status: SourceToBundleComparisonItem["status"], +): number => items.filter((item) => item.status === status).length; + +interface CoverageFactors { + readonly input: SourceToBundleComparisonInput; + readonly omittedSourceFiles: number; + readonly omittedApplicationNodes: number; + readonly omittedCandidateEvaluations: number; + readonly omittedCandidateReferences: number; + readonly pathIndexTruncated: boolean; +} + +const coverageStatus = ( + factors: CoverageFactors, +): SourceToBundleComparisonResult["coverage"]["status"] => { + if ( + factors.omittedSourceFiles > 0 || + factors.omittedApplicationNodes > 0 || + factors.omittedCandidateEvaluations > 0 || + factors.omittedCandidateReferences > 0 || + factors.pathIndexTruncated + ) + return "truncated"; + return factors.input.reference.inventory_state === "complete" && + factors.input.application.graph.coverage.status === "complete" + ? "complete-within-inputs" + : "partial"; +}; + +const comparisonLimitations = (factors: CoverageFactors): string[] => + uniqueSorted([ + "Static digest and location signals do not establish runtime behavior, semantic equivalence, or absence of dynamically generated modules.", + ...factors.input.reference.limitations, + ...factors.input.application.graph.limitations, + ...(factors.input.reference.inventory_state === "complete" + ? [] + : [ + `Historical source inventory is ${factors.input.reference.inventory_state}; unmatched files remain unknown.`, + ]), + ...(factors.input.application.graph.coverage.status === "complete" + ? [] + : [ + `Application graph coverage is ${factors.input.application.graph.coverage.status}; unmatched files remain unknown.`, + ]), + ...(factors.omittedSourceFiles > 0 + ? [ + `${String(factors.omittedSourceFiles)} historical source files were omitted by max_source_files.`, + ] + : []), + ...(factors.omittedApplicationNodes > 0 + ? [ + `${String(factors.omittedApplicationNodes)} source-bearing application nodes were omitted by max_application_nodes.`, + ] + : []), + ...(factors.omittedCandidateEvaluations > 0 + ? [ + `${String(factors.omittedCandidateEvaluations)} candidate evaluations were omitted by max_candidate_evaluations.`, + ] + : []), + ...(factors.omittedCandidateReferences > 0 + ? [ + `${String(factors.omittedCandidateReferences)} candidate references were omitted from item output.`, + ] + : []), + ...(factors.pathIndexTruncated + ? [ + "At least one current path exceeded the 128-segment suffix index bound.", + ] + : []), + ]); + +const hasSignal = ( + candidate: SourceToBundleCandidate, + kind: SourceToBundleCandidate["signals"][number]["kind"], +): boolean => candidate.signals.some((signal) => signal.kind === kind); + +const compareCandidates = ( + left: SourceToBundleCandidate, + right: SourceToBundleCandidate, +): number => + right.score - left.score || + compareText(left.current_node_id, right.current_node_id); + +const digestCanonical = (value: unknown): string => { + const encoded = canonicalize(value); + if (encoded === undefined) + throw new TypeError("Source-to-bundle comparison could not canonicalize"); + return createHash("sha256").update(encoded).digest("hex"); +}; + +const uniqueSorted = (values: readonly string[]): string[] => + [...new Set(values)].sort(compareText); + +const compareText = (left: string, right: string): number => + left < right ? -1 : left > right ? 1 : 0; diff --git a/src/domain/sourceToBundleComparisonSchemas.ts b/src/domain/sourceToBundleComparisonSchemas.ts new file mode 100644 index 000000000..a79e79032 --- /dev/null +++ b/src/domain/sourceToBundleComparisonSchemas.ts @@ -0,0 +1,157 @@ +import { z } from "zod"; + +import { evidenceSchema } from "./evidence.js"; +import { JAVASCRIPT_APPLICATION_NODE_KINDS } from "./javascriptApplicationGraphSchemas.js"; +import { historicalSourceGraphSchema } from "./referenceSourceGraph.js"; + +const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u); +const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u); +const nodeIdSchema = z.string().regex(/^jag_node_[a-f0-9]{64}$/u); +const boundedTextSchema = z.string().min(1).max(4_096); + +export const SOURCE_TO_BUNDLE_SIGNAL_WEIGHTS = { + "exact-source-digest": 100, + "source-map-original-path": 70, + "current-path-exact": 60, + "current-path-suffix": 40, + "basename-match": 20, + "language-extension": 5, +} as const; + +const sourceToBundleSignalKindSchema = z.enum([ + "exact-source-digest", + "source-map-original-path", + "current-path-exact", + "current-path-suffix", + "basename-match", + "language-extension", +]); + +const sourceToBundleLimitsSchema = z.strictObject({ + max_source_files: z.number().int().min(1).max(100_000).default(10_000), + max_application_nodes: z.number().int().min(1).max(100_000).default(20_000), + max_candidate_nodes: z.number().int().min(1).max(1_000).default(100), + max_candidate_evaluations: z + .number() + .int() + .min(1) + .max(10_000_000) + .default(1_000_000), +}); + +/** Historical source inventory, authenticated application Evidence, and bounds. */ +export const compareSourceToBundleInputSchema = z.strictObject({ + reference: historicalSourceGraphSchema, + application: evidenceSchema, + limits: sourceToBundleLimitsSchema.default({ + max_source_files: 10_000, + max_application_nodes: 20_000, + max_candidate_nodes: 100, + max_candidate_evaluations: 1_000_000, + }), + unknown_registry_approved: z.literal(true).optional(), +}); + +const sourceToBundleSignalSchema = z.strictObject({ + kind: sourceToBundleSignalKindSchema, + weight: z.number().int().min(1).max(100), + source_value: boundedTextSchema, + current_values: z.array(boundedTextSchema).min(1).max(64), +}); + +const sourceToBundleCandidateSchema = z.strictObject({ + current_node_id: nodeIdSchema, + current_node_kind: z.enum(JAVASCRIPT_APPLICATION_NODE_KINDS), + score: z.number().int().min(1).max(300), + confidence: z.enum(["exact", "high", "medium", "low"]), + signals: z.array(sourceToBundleSignalSchema).min(1).max(6), +}); + +const sourceToBundleItemSchema = z.strictObject({ + mapping_id: z.string().regex(/^stbc_item_[a-f0-9]{64}$/u), + source_path: boundedTextSchema, + source_sha256: digestSchema.nullable(), + source_language: z.string().min(1).max(100).nullable(), + status: z.enum([ + "unchanged", + "modified", + "removed", + "split", + "merged", + "duplicated", + "unknown", + ]), + confidence: z.enum(["exact", "high", "medium", "unknown"]), + current_node_ids: z.array(nodeIdSchema).max(1_000), + candidates: z.array(sourceToBundleCandidateSchema).max(1_000), + omitted_candidates: z.number().int().min(0), + limitations: z.array(boundedTextSchema).max(100), +}); + +/** Deterministic, evidence-bearing historical-source to shipped-bundle comparison. */ +export const sourceToBundleComparisonResultSchema = z.strictObject({ + schema_version: z.literal(1), + comparison_id: z.string().regex(/^stbc_[a-f0-9]{64}$/u), + reference: z.strictObject({ + root_sha256: digestSchema, + inventory_state: z.enum(["complete", "partial", "unknown"]), + }), + application: z.strictObject({ + evidence_id: evidenceIdSchema, + graph_id: z.string().regex(/^jag_[a-f0-9]{64}$/u), + root_artifact_sha256: digestSchema, + }), + scoring: z.strictObject({ + algorithm: z.literal("rea-source-to-bundle-signals/v1"), + minimum_candidate_score: z.literal(20), + weights: z.array( + z.strictObject({ + signal: sourceToBundleSignalKindSchema, + weight: z.number().int().min(1).max(100), + }), + ), + }), + summary: z.strictObject({ + unchanged: z.number().int().min(0), + modified: z.number().int().min(0), + removed: z.number().int().min(0), + split: z.number().int().min(0), + merged: z.number().int().min(0), + duplicated: z.number().int().min(0), + unknown: z.number().int().min(0), + }), + items: z.array(sourceToBundleItemSchema).max(100_000), + unmapped_current_node_ids: z.array(nodeIdSchema).max(100_000), + coverage: z.strictObject({ + status: z.enum(["complete-within-inputs", "partial", "truncated"]), + reference_inventory_state: z.enum(["complete", "partial", "unknown"]), + application_graph_status: z.enum([ + "complete", + "partial", + "unknown", + "unavailable", + ]), + retained_source_files: z.number().int().min(0), + omitted_source_files: z.number().int().min(0), + retained_application_nodes: z.number().int().min(0), + omitted_application_nodes: z.number().int().min(0), + candidate_evaluations: z.number().int().min(0), + omitted_candidate_evaluations: z.number().int().min(0), + omitted_candidate_references: z.number().int().min(0), + omitted_unmapped_current_nodes: z.number().int().min(0), + }), + evidence_links: z.array(evidenceIdSchema).min(1).max(1), + limitations: z.array(boundedTextSchema).max(1_000), +}); + +export type CompareSourceToBundleInput = z.infer< + typeof compareSourceToBundleInputSchema +>; +export type SourceToBundleComparisonResult = z.infer< + typeof sourceToBundleComparisonResultSchema +>; +export type SourceToBundleComparisonItem = + SourceToBundleComparisonResult["items"][number]; +export type SourceToBundleCandidate = + SourceToBundleComparisonItem["candidates"][number]; +export type SourceToBundleSignal = SourceToBundleCandidate["signals"][number]; diff --git a/src/domain/sourceToBundleSignals.ts b/src/domain/sourceToBundleSignals.ts new file mode 100644 index 000000000..f3d67ac4f --- /dev/null +++ b/src/domain/sourceToBundleSignals.ts @@ -0,0 +1,314 @@ +import { posix } from "node:path"; + +import type { ApplicationNode } from "./javascriptApplicationGraphSchemas.js"; +import type { HistoricalSourceGraph } from "./referenceSourceGraph.js"; +import { + SOURCE_TO_BUNDLE_SIGNAL_WEIGHTS, + type SourceToBundleCandidate, + type SourceToBundleSignal, +} from "./sourceToBundleComparisonSchemas.js"; + +type SourceFile = Extract< + HistoricalSourceGraph["entries"][number], + { readonly kind: "file" } +>; + +type CurrentPathKind = + | "source-map-original" + | "canonical-path" + | "observation-path"; + +interface CurrentPath { + readonly kind: CurrentPathKind; + readonly value: string; +} + +interface CurrentProjection { + readonly node: ApplicationNode; + readonly digests: ReadonlySet; + readonly paths: readonly CurrentPath[]; +} + +/** Indexed current-node facts used by deterministic source matching. */ +export interface SourceToBundleCandidateIndex { + readonly nodes: ReadonlyMap; + readonly byDigest: ReadonlyMap>; + readonly byPathSuffix: ReadonlyMap>; + readonly byBasename: ReadonlyMap>; + readonly pathIndexTruncated: boolean; +} + +const RELEVANT_NODE_KINDS = new Set([ + "javascript-asset", + "javascript-module", + "source-module", +]); +const PATH_PROPERTIES = [ + "path", + "source", + "original_source", + "module_path", + "resolved_path", + "declared_path", +] as const; +const MAX_PATH_SUFFIXES = 128; + +/** Keep source-bearing application nodes in stable identifier order. */ +export const sourceBearingNodes = ( + nodes: readonly ApplicationNode[], +): ApplicationNode[] => + nodes + .filter(({ kind }) => RELEVANT_NODE_KINDS.has(kind)) + .sort((left, right) => compareText(left.node_id, right.node_id)); + +/** Keep historical source files in stable path order. */ +export const historicalSourceFiles = ( + graph: HistoricalSourceGraph, +): SourceFile[] => + graph.entries + .filter( + (entry): entry is SourceFile => + entry.kind === "file" && entry.classifications.includes("source"), + ) + .sort((left, right) => compareText(left.path, right.path)); + +/** Build bounded-key indices without assigning fuzzy matches. */ +export const buildSourceToBundleCandidateIndex = ( + nodes: readonly ApplicationNode[], +): SourceToBundleCandidateIndex => { + const projections = nodes.map(projectCurrentNode); + const byDigest = new Map>(); + const byPathSuffix = new Map>(); + const byBasename = new Map>(); + let pathIndexTruncated = false; + for (const projection of projections) { + for (const digest of projection.digests) + addIndexValue(byDigest, digest, projection.node.node_id); + for (const path of projection.paths) { + const suffixes = pathSuffixes(path.value); + pathIndexTruncated ||= suffixes.truncated; + for (const suffix of suffixes.values) + addIndexValue(byPathSuffix, suffix, projection.node.node_id); + addIndexValue( + byBasename, + posix.basename(path.value), + projection.node.node_id, + ); + } + } + return { + nodes: new Map( + projections.map((projection) => [projection.node.node_id, projection]), + ), + byDigest, + byPathSuffix, + byBasename, + pathIndexTruncated, + }; +}; + +/** Candidate identifiers ordered by strongest available signal, then ID. */ +export const candidateIdsForSource = ( + source: SourceFile, + index: SourceToBundleCandidateIndex, +): string[] => { + const groups = [ + source.sha256 === null ? undefined : index.byDigest.get(source.sha256), + index.byPathSuffix.get(source.path), + index.byBasename.get(posix.basename(source.path)), + ]; + const seen = new Set(); + const output: string[] = []; + for (const group of groups) { + if (group === undefined) continue; + for (const nodeId of [...group].sort(compareText)) { + if (seen.has(nodeId)) continue; + seen.add(nodeId); + output.push(nodeId); + } + } + return output; +}; + +/** Score one indexed candidate from explicit, caller-visible signals. */ +export const scoreSourceToBundleCandidate = ( + source: SourceFile, + nodeId: string, + index: SourceToBundleCandidateIndex, +): SourceToBundleCandidate | null => { + const projection = index.nodes.get(nodeId); + if (projection === undefined) return null; + const signals = candidateSignals(source, projection); + const score = signals.reduce((total, signal) => total + signal.weight, 0); + if (score < 20) return null; + return { + current_node_id: projection.node.node_id, + current_node_kind: projection.node.kind, + score, + confidence: candidateConfidence(signals), + signals, + }; +}; + +const projectCurrentNode = (node: ApplicationNode): CurrentProjection => { + const digests = new Set(); + const paths: CurrentPath[] = []; + if (node.identity.strategy === "content-digest") + digests.add(node.identity.sha256); + if (node.identity.strategy === "source-map-original") { + if (node.identity.source_sha256 !== null) + digests.add(node.identity.source_sha256); + addPath(paths, "source-map-original", node.identity.original_source); + } + if (node.identity.strategy === "canonical-path") + addPath(paths, "canonical-path", node.identity.path); + for (const observation of node.observations) { + const sourceDigest = observation.properties.source_sha256; + if (typeof sourceDigest === "string" && isDigest(sourceDigest)) + digests.add(sourceDigest); + for (const key of PATH_PROPERTIES) + addJsonPath(paths, observation.properties[key]); + } + return { + node, + digests, + paths: uniquePaths(paths), + }; +}; + +const candidateSignals = ( + source: SourceFile, + current: CurrentProjection, +): SourceToBundleSignal[] => { + const signals: SourceToBundleSignal[] = []; + if (source.sha256 !== null && current.digests.has(source.sha256)) + signals.push(signal("exact-source-digest", source.sha256, [source.sha256])); + const sourceMapPaths = matchingPaths( + source.path, + current.paths.filter(({ kind }) => kind === "source-map-original"), + ); + if (sourceMapPaths.length > 0) + signals.push( + signal("source-map-original-path", source.path, sourceMapPaths), + ); + const otherPaths = current.paths.filter( + ({ kind }) => kind !== "source-map-original", + ); + const exactPaths = otherPaths + .filter(({ value }) => value === source.path) + .map(({ value }) => value); + if (exactPaths.length > 0) + signals.push(signal("current-path-exact", source.path, exactPaths)); + const suffixPaths = otherPaths + .filter( + ({ value }) => value !== source.path && value.endsWith(`/${source.path}`), + ) + .map(({ value }) => value); + if (suffixPaths.length > 0) + signals.push(signal("current-path-suffix", source.path, suffixPaths)); + const basename = posix.basename(source.path); + const basenamePaths = current.paths + .filter(({ value }) => posix.basename(value) === basename) + .map(({ value }) => value); + if (basenamePaths.length > 0) + signals.push(signal("basename-match", basename, basenamePaths)); + const extension = posix.extname(source.path).toLowerCase(); + const extensionPaths = current.paths + .filter(({ value }) => posix.extname(value).toLowerCase() === extension) + .map(({ value }) => value); + if (extension !== "" && extensionPaths.length > 0) + signals.push(signal("language-extension", extension, extensionPaths)); + return signals; +}; + +const signal = ( + kind: SourceToBundleSignal["kind"], + sourceValue: string, + currentValues: readonly string[], +): SourceToBundleSignal => ({ + kind, + weight: SOURCE_TO_BUNDLE_SIGNAL_WEIGHTS[kind], + source_value: sourceValue, + current_values: [...new Set(currentValues)].sort(compareText).slice(0, 64), +}); + +const candidateConfidence = ( + signals: readonly SourceToBundleSignal[], +): SourceToBundleCandidate["confidence"] => { + const kinds = new Set(signals.map(({ kind }) => kind)); + if (kinds.has("exact-source-digest")) return "exact"; + if (kinds.has("source-map-original-path") || kinds.has("current-path-exact")) + return "high"; + return kinds.has("current-path-suffix") ? "medium" : "low"; +}; + +const matchingPaths = ( + sourcePath: string, + currentPaths: readonly CurrentPath[], +): string[] => + currentPaths + .filter( + ({ value }) => value === sourcePath || value.endsWith(`/${sourcePath}`), + ) + .map(({ value }) => value); + +const addJsonPath = (paths: CurrentPath[], value: unknown): void => { + if (typeof value === "string") addPath(paths, "observation-path", value); +}; + +const addPath = ( + paths: CurrentPath[], + kind: CurrentPathKind, + raw: string, +): void => { + const value = normalizeCurrentPath(raw); + if (value !== null) paths.push({ kind, value }); +}; + +const normalizeCurrentPath = (raw: string): string | null => { + const withoutQuery = raw.split(/[?#]/u, 1)[0] ?? ""; + const withoutScheme = withoutQuery.replace(/^[a-z][a-z0-9+.-]*:\/\/+/iu, ""); + const parts = withoutScheme + .replaceAll("\\", "/") + .split("/") + .filter((part) => part !== "" && part !== "."); + if (parts.length === 0 || parts.includes("..")) return null; + return parts.join("/"); +}; + +const uniquePaths = (paths: readonly CurrentPath[]): CurrentPath[] => + [ + ...new Map( + paths.map((path) => [`${path.kind}\0${path.value}`, path]), + ).values(), + ].sort((left, right) => + compareText(`${left.kind}\0${left.value}`, `${right.kind}\0${right.value}`), + ); + +const pathSuffixes = ( + path: string, +): { readonly values: readonly string[]; readonly truncated: boolean } => { + const parts = path.split("/"); + const start = Math.max(0, parts.length - MAX_PATH_SUFFIXES); + return { + values: parts + .slice(start) + .map((_, index) => parts.slice(start + index).join("/")), + truncated: start > 0, + }; +}; + +const addIndexValue = ( + index: Map>, + key: string, + nodeId: string, +): void => { + const values = index.get(key) ?? new Set(); + values.add(nodeId); + index.set(key, values); +}; + +const isDigest = (value: string): boolean => /^[a-f0-9]{64}$/u.test(value); + +const compareText = (left: string, right: string): number => + left < right ? -1 : left > right ? 1 : 0; diff --git a/src/domain/webCaptureDiff.ts b/src/domain/webCaptureDiff.ts index a89b2bb0d..ca267d88c 100644 --- a/src/domain/webCaptureDiff.ts +++ b/src/domain/webCaptureDiff.ts @@ -12,7 +12,8 @@ import { type WebMcpDiscovery, } from "./webMcpDiscovery.js"; -const captureSnapshotSchema = z.object({ +/** One normalized passive page snapshot accepted by capture comparison. */ +export const captureSnapshotSchema = z.object({ inspection: webPageInspectionSchema, webmcp: webMcpDiscoverySchema.nullable().default(null), }); @@ -38,6 +39,13 @@ const dimensionSchema = z.object({ omitted_changes: z.number().int().min(0), reason: z.string().nullable(), }); +const legacyUnknownDimension = { + status: "unknown" as const, + total_changes: 0, + changes: [], + omitted_changes: 0, + reason: "Dimension was not recorded by this version 1 capture diff.", +}; /** Completeness-aware changes across stable browser evidence dimensions. */ export const webCaptureDiffSchema = z.object({ @@ -52,6 +60,8 @@ export const webCaptureDiffSchema = z.object({ network: dimensionSchema, metadata: dimensionSchema, webmcp: dimensionSchema, + accessibility: dimensionSchema.default(legacyUnknownDimension), + storage: dimensionSchema.default(legacyUnknownDimension), }), limitations: z.array(z.string()), }); @@ -64,16 +74,67 @@ type Change = Dimension["changes"][number]; export const compareWebCaptures = ( input: CompareWebCapturesInput, ): WebCaptureDiff => { - let remaining = input.max_changes; - const dimension = ( + const remaining = { value: input.max_changes }; + const before = input.before.inspection; + const after = input.after.inspection; + const dimensions = compareWebCaptureDimensions(input, remaining); + const statuses = Object.values(dimensions).map(({ status }) => status); + return webCaptureDiffSchema.parse({ + schema_version: 1, + overall_status: statuses.includes("changed") + ? "changed" + : statuses.includes("unknown") + ? "unknown" + : "unchanged", + before_target: { + target_id: before.target.target_id, + url: before.target.url, + }, + after_target: { + target_id: after.target.target_id, + url: after.target.url, + }, + dimensions, + limitations: [ + "A changed status proves an observed difference; an unknown status means absence could not be established from capture completeness.", + "Network comparison covers only activity observed after each CDP attachment.", + "Accessibility roles, ignored state, text, and hierarchy are compared only when the accessibility tree was fully captured and text capture was approved and not truncated.", + "Storage key inventories are compared only when approved and complete; usage and quota are compared only when reported. Redacted content is compared through complete SHA-256 fingerprints.", + ], + }); +}; + +const compareIdentities = ( + before: ReadonlyMap, + after: ReadonlyMap, +): Change[] => { + const changes: Change[] = []; + for (const [identity, fingerprint] of before) { + const next = after.get(identity); + if (next === undefined) changes.push({ identity, change: "removed" }); + else if (next !== fingerprint) + changes.push({ identity, change: "modified" }); + } + for (const identity of after.keys()) + if (!before.has(identity)) changes.push({ identity, change: "added" }); + return changes.sort( + (left, right) => + left.identity.localeCompare(right.identity) || + left.change.localeCompare(right.change), + ); +}; + +const compareDimension = + (remaining: { value: number }) => + ( before: ReadonlyMap, after: ReadonlyMap, complete: boolean, reason: string, ): Dimension => { const all = compareIdentities(before, after); - const retained = all.slice(0, remaining); - remaining -= retained.length; + const retained = all.slice(0, remaining.value); + remaining.value -= retained.length; return { status: all.length > 0 ? "changed" : complete ? "unchanged" : "unknown", total_changes: all.length, @@ -82,9 +143,84 @@ export const compareWebCaptures = ( reason: all.length === 0 && !complete ? reason : null, }; }; + +const accessibilityDimension = ( + before: WebPageInspection, + after: WebPageInspection, + remaining: { value: number }, +): Dimension => { + const beforeAccess = accessibilityComparable(before); + const afterAccess = accessibilityComparable(after); + const textComparable = beforeAccess.text && afterAccess.text; + const nodesComparable = beforeAccess.nodes && afterAccess.nodes; + return compareDimension(remaining)( + singleton( + "accessibility_tree", + digest( + accessibilityProjection( + before.accessibility, + textComparable, + nodesComparable, + ), + ), + ), + singleton( + "accessibility_tree", + digest( + accessibilityProjection( + after.accessibility, + textComparable, + nodesComparable, + ), + ), + ), + beforeAccess.complete && afterAccess.complete, + "Accessibility tree or text capture was incomplete in at least one observation.", + ); +}; + +const storageDimension = ( + before: WebPageInspection, + after: WebPageInspection, + remaining: { value: number }, +): Dimension => { + const keysComplete = + storageKeysComparable(before) && storageKeysComparable(after); + const usageComplete = + before.storage.usage_bytes !== null && + after.storage.usage_bytes !== null && + before.storage.quota_bytes !== null && + after.storage.quota_bytes !== null; + const comparableFingerprints = storageFingerprintIdentities( + before.storage, + after.storage, + ); + return compareDimension(remaining)( + storageMap( + before.storage, + keysComplete, + usageComplete, + comparableFingerprints, + ), + storageMap( + after.storage, + keysComplete, + usageComplete, + comparableFingerprints, + ), + storageComparable(before, after, keysComplete, usageComplete), + "Storage fingerprints, inventories, usage, or quota were unavailable or truncated in at least one observation.", + ); +}; + +const compareWebCaptureDimensions = ( + input: CompareWebCapturesInput, + remaining: { value: number }, +): WebCaptureDiff["dimensions"] => { const before = input.before.inspection; const after = input.after.inspection; - const dimensions = { + const dimension = compareDimension(remaining); + return { dom_structure: dimension( singleton("document", digest(domProjection(before))), singleton("document", digest(domProjection(after))), @@ -132,49 +268,9 @@ export const compareWebCaptures = ( webMcpComplete(input.before.webmcp) && webMcpComplete(input.after.webmcp), "WebMCP discovery was unavailable or incomplete in at least one capture.", ), + accessibility: accessibilityDimension(before, after, remaining), + storage: storageDimension(before, after, remaining), }; - const statuses = Object.values(dimensions).map(({ status }) => status); - return webCaptureDiffSchema.parse({ - schema_version: 1, - overall_status: statuses.includes("changed") - ? "changed" - : statuses.includes("unknown") - ? "unknown" - : "unchanged", - before_target: { - target_id: before.target.target_id, - url: before.target.url, - }, - after_target: { - target_id: after.target.target_id, - url: after.target.url, - }, - dimensions, - limitations: [ - "A changed status proves an observed difference; an unknown status means absence could not be established from capture completeness.", - "Network comparison covers only activity observed after each CDP attachment.", - ], - }); -}; - -const compareIdentities = ( - before: ReadonlyMap, - after: ReadonlyMap, -): Change[] => { - const changes: Change[] = []; - for (const [identity, fingerprint] of before) { - const next = after.get(identity); - if (next === undefined) changes.push({ identity, change: "removed" }); - else if (next !== fingerprint) - changes.push({ identity, change: "modified" }); - } - for (const identity of after.keys()) - if (!before.has(identity)) changes.push({ identity, change: "added" }); - return changes.sort( - (left, right) => - left.identity.localeCompare(right.identity) || - left.change.localeCompare(right.change), - ); }; const keyed = ( @@ -284,3 +380,125 @@ const digest = (value: unknown): string => { if (encoded === undefined) throw new TypeError("Expected canonical JSON"); return createHash("sha256").update(encoded).digest("hex"); }; + +const accessibilityComparable = ( + inspection: WebPageInspection, +): { + readonly text: boolean; + readonly nodes: boolean; + readonly complete: boolean; +} => { + const text = + inspection.accessibility.text_capture.status === "included" && + inspection.accessibility.text_capture.excluded_fields === 0 && + inspection.accessibility.text_capture.truncated_fields === 0; + const nodes = + inspection.accessibility.total_nodes === + inspection.accessibility.nodes.length; + const complete = + text && nodes && sectionsComplete(inspection, ["accessibility"]); + return { text, nodes, complete }; +}; + +const accessibilityProjection = ( + accessibility: WebPageInspection["accessibility"], + includeText: boolean, + includeNodes: boolean, +): unknown => { + const result = { + total_nodes: accessibility.total_nodes, + } as { total_nodes: number; nodes?: readonly unknown[] }; + if (!includeNodes) return result; + const nodeIndexes = new Map( + accessibility.nodes.map((node, index) => [node.node_id, index]), + ); + result.nodes = accessibility.nodes.map((node) => ({ + parent_index: + node.parent_id === null ? null : (nodeIndexes.get(node.parent_id) ?? -1), + role: node.role, + ignored: node.ignored, + states: node.states, + ...(includeText ? { name: node.name, description: node.description } : {}), + })); + return result; +}; + +const storageKeysComparable = (inspection: WebPageInspection): boolean => + sectionsComplete(inspection, ["storage_keys"]); + +const storageComparable = ( + before: WebPageInspection, + after: WebPageInspection, + keysComplete: boolean, + usageComplete: boolean, +): boolean => + keysComplete && + usageComplete && + before.storage.fingerprints_complete && + after.storage.fingerprints_complete; + +const storageMap = ( + storage: WebPageInspection["storage"], + includeKeys: boolean, + includeUsage: boolean, + fingerprintIdentities: ReadonlySet, +): ReadonlyMap => { + const map = new Map([ + [ + "storage:summary", + digest({ + origin: storage.origin, + values_redacted: storage.values_redacted, + ...(includeUsage + ? { + usage_bytes: storage.usage_bytes, + quota_bytes: storage.quota_bytes, + } + : {}), + }), + ], + ]); + if (!includeKeys) return map; + const add = (kind: string, keys: readonly string[]) => { + for (const key of keys) { + map.set(`storage:${kind}:${key}`, digest(key)); + } + }; + add("local_storage", storage.local_storage_keys); + add("session_storage", storage.session_storage_keys); + add("indexed_db", storage.indexed_db_names); + add("cache", storage.cache_names); + for (const fingerprint of storage.content_fingerprints) { + const identity = `${fingerprint.scope}:${fingerprint.identity_sha256}`; + if (!fingerprintIdentities.has(identity)) continue; + map.set(`storage:content:${identity}`, digest(fingerprint.value_sha256)); + } + return map; +}; + +const storageFingerprintIdentities = ( + before: WebPageInspection["storage"], + after: WebPageInspection["storage"], +): ReadonlySet => { + const beforeComplete = completeStorageFingerprints(before); + const afterComplete = completeStorageFingerprints(after); + if (before.fingerprints_complete && after.fingerprints_complete) + return new Set([...beforeComplete.keys(), ...afterComplete.keys()]); + return new Set( + [...beforeComplete.keys()].filter((identity) => + afterComplete.has(identity), + ), + ); +}; + +const completeStorageFingerprints = ( + storage: WebPageInspection["storage"], +): ReadonlyMap => + new Map( + storage.content_fingerprints + .filter(({ complete }) => complete) + .map(({ scope, identity_sha256, value_sha256 }) => [ + `${scope}:${identity_sha256}`, + value_sha256, + ]), + ); diff --git a/src/domain/webScreenshot.ts b/src/domain/webScreenshot.ts index 7ae7a5645..d55007275 100644 --- a/src/domain/webScreenshot.ts +++ b/src/domain/webScreenshot.ts @@ -11,7 +11,7 @@ import { const MAX_SCREENSHOT_BYTES = 8 * 1_024 * 1_024; /** Self-verifying inline PNG artifact for CLI/MCP parity. */ -const webScreenshotArtifactSchema = z +export const webScreenshotArtifactSchema = z .object({ uri: z.string().regex(/^rea:\/\/web-screenshot\/sha256\/[a-f0-9]{64}$/u), sha256: z.string().regex(/^[a-f0-9]{64}$/u), diff --git a/src/hopper/HopperClient.ts b/src/hopper/HopperClient.ts index 699ab8415..6d2d0f2b7 100644 --- a/src/hopper/HopperClient.ts +++ b/src/hopper/HopperClient.ts @@ -5,11 +5,13 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { + AnalysisCapabilityUnavailableError, HopperCancelledError, type AnalysisError, type HopperError, HopperProcessError, HopperProtocolError, + HopperRemoteError, HopperStartError, hopperStartupFailure, HopperTimeoutError, @@ -279,10 +281,21 @@ export class HopperClient { readonly timeoutMs?: number; readonly progress?: ProgressReporter; } = {}, - ): Promise> { + ): Promise> { const started = await this.start(options.signal); if (!started.ok) return started; - return this.#request(name, arguments_, options); + const result = await this.#request(name, arguments_, options); + return !result.ok && + result.error instanceof HopperRemoteError && + result.error.diagnosticType === "capability_unavailable" + ? err( + new AnalysisCapabilityUnavailableError( + "hopper", + name, + result.error.safeMessage, + ), + ) + : result; } /** Stop the bridge, settle outstanding requests, and remove session artifacts. */ diff --git a/src/hopper/HopperProvider.ts b/src/hopper/HopperProvider.ts index fabbaa7f5..a7f641cae 100644 --- a/src/hopper/HopperProvider.ts +++ b/src/hopper/HopperProvider.ts @@ -75,7 +75,7 @@ const CAPABILITIES: readonly CapabilityDescriptor[] = Object.freeze( }), limits: Object.freeze({ maxResults: null, - maxPayloadBytes: null, + maxPayloadBytes: contract.name === "read_bytes" ? 4_096 : null, timeoutMs: null, }), limitations: Object.freeze([ diff --git a/src/hopper/protocol.ts b/src/hopper/protocol.ts index 92571a42e..370b775ff 100644 --- a/src/hopper/protocol.ts +++ b/src/hopper/protocol.ts @@ -19,6 +19,7 @@ const responseSchema = z.union([ "remote", "authorization", "invalid_request", + "capability_unavailable", "bridge_exception", ]) .default("remote"), diff --git a/src/main.ts b/src/main.ts index 96a741a1d..3417aa330 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,6 +12,8 @@ import { projectAnalysisError } from "./domain/errors.js"; import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js"; import { CdpBrowserProvider } from "./browser/CdpBrowserProvider.js"; import { CdpElectronProvider } from "./browser/CdpElectronProvider.js"; +import { V8InspectorProvider } from "./browser/V8InspectorProvider.js"; +import { PlaywrightBrowserScenarioProvider } from "./browser/PlaywrightBrowserScenarioProvider.js"; import type { RuntimeDependencies } from "./main/types.js"; import { SERVER_START_FAILED } from "./main/messages.js"; import { createRuntimeState } from "./main/state.js"; @@ -72,7 +74,9 @@ export const run = async ( const session = createBinarySession(config.value, logger); const browserObservation = new CdpBrowserProvider(); + const browserScenarioCapture = new PlaywrightBrowserScenarioProvider(); const electronObservation = new CdpElectronProvider(); + const javascriptRuntimeObservation = new V8InspectorProvider(); const opened = await openInitialTarget( session, config.value, @@ -85,7 +89,9 @@ export const run = async ( logger, serverLogger, browserObservation, + browserScenarioCapture, electronObservation, + javascriptRuntimeObservation, permissionAuthority: permissionAuthority.value, runtimeState, }); diff --git a/src/main/transport.ts b/src/main/transport.ts index 50c734f29..7edb93be4 100644 --- a/src/main/transport.ts +++ b/src/main/transport.ts @@ -3,6 +3,8 @@ import type { StdioServerHandle } from "@modelcontextprotocol/server/stdio"; import type { BinarySession } from "../application/BinarySession.js"; import type { CdpBrowserProvider } from "../browser/CdpBrowserProvider.js"; import type { CdpElectronProvider } from "../browser/CdpElectronProvider.js"; +import type { V8InspectorProvider } from "../browser/V8InspectorProvider.js"; +import type { PlaywrightBrowserScenarioProvider } from "../browser/PlaywrightBrowserScenarioProvider.js"; import type { PermissionAuthority } from "../application/PermissionAuthority.js"; import type { Logger } from "../logger.js"; import { createServer } from "../server/createServer.js"; @@ -19,7 +21,9 @@ interface ServerContext { readonly logger: Logger; readonly serverLogger: Logger; readonly browserObservation: CdpBrowserProvider; + readonly browserScenarioCapture: PlaywrightBrowserScenarioProvider; readonly electronObservation: CdpElectronProvider; + readonly javascriptRuntimeObservation: V8InspectorProvider; readonly permissionAuthority: PermissionAuthority; readonly runtimeState: RuntimeState; } @@ -55,7 +59,10 @@ export const startMcpTransport = async ( serverContext.runtimeState.snapshotPolicy, permissionAuthority: serverContext.permissionAuthority, browserObservation: serverContext.browserObservation, + browserScenarioCapture: serverContext.browserScenarioCapture, electronObservation: serverContext.electronObservation, + javascriptRuntimeObservation: + serverContext.javascriptRuntimeObservation, artifactIntegrityContinueEnabled: () => serverContext.runtimeState.currentConfig .artifactIntegrityContinueEnabled, @@ -63,37 +70,8 @@ export const startMcpTransport = async ( serverContext.runtimeState.javascriptReplayPolicy, managedRuntimePolicy: serverContext.runtimeState.managedRuntimePolicy, - availabilityPolicy: () => ({ - processCaptureEnabled: - serverContext.runtimeState.currentConfig.processExecutionPolicy - .enabled, - evidenceFileRoots: - serverContext.runtimeState.currentConfig.evidenceFilePolicy - .roots.length, - investigationInputRoots: - serverContext.runtimeState.currentConfig.investigationInputRoots - .length, - browserObservationEnabled: - serverContext.runtimeState.currentConfig - .browserObservationEnabled && - serverContext.runtimeState.currentConfig.browserCdpEndpoints - .length > 0 && - serverContext.runtimeState.currentConfig.browserAllowedOrigins - .length > 0, - electronObservationEnabled: - serverContext.runtimeState.currentConfig - .electronObservationEnabled && - serverContext.runtimeState.currentConfig.electronCdpEndpoints - .length > 0 && - serverContext.runtimeState.currentConfig.electronFileRoots - .length > 0, - javascriptReplayEnabled: - serverContext.runtimeState.currentConfig.javascriptReplayPolicy - .enabled, - managedRuntimeEnabled: - serverContext.runtimeState.currentConfig.managedRuntimePolicy - .enabled, - }), + availabilityPolicy: () => + runtimeAvailability(serverContext.runtimeState), }, ); liveServers.add(server); @@ -114,3 +92,29 @@ export const startMcpTransport = async ( } return { ok: true, handle, liveServers }; }; + +const runtimeAvailability = (state: RuntimeState) => ({ + processCaptureEnabled: state.currentConfig.processExecutionPolicy.enabled, + evidenceFileRoots: state.currentConfig.evidenceFilePolicy.roots.length, + investigationInputRoots: state.currentConfig.investigationInputRoots.length, + browserObservationEnabled: + state.currentConfig.browserObservationEnabled && + state.currentConfig.browserCdpEndpoints.length > 0 && + state.currentConfig.browserAllowedOrigins.length > 0, + browserScenarioEnabled: + state.currentConfig.browserScenarioPolicy.enabled && + state.currentConfig.browserScenarioPolicy.allowedOrigins.length > 0 && + (state.currentConfig.browserScenarioPolicy.executableRoots.length > 0 || + state.currentConfig.browserScenarioPolicy.cdpEndpoints.length > 0), + electronObservationEnabled: + state.currentConfig.electronObservationEnabled && + state.currentConfig.electronCdpEndpoints.length > 0 && + state.currentConfig.electronFileRoots.length > 0, + v8InspectorObservationEnabled: + state.currentConfig.v8InspectorObservationEnabled && + state.currentConfig.v8InspectorEndpoints.length > 0 && + (state.currentConfig.v8InspectorFileRoots.length > 0 || + state.currentConfig.v8InspectorAllowedOrigins.length > 0), + javascriptReplayEnabled: state.currentConfig.javascriptReplayPolicy.enabled, + managedRuntimeEnabled: state.currentConfig.managedRuntimePolicy.enabled, +}); diff --git a/src/mcpDoctor.ts b/src/mcpDoctor.ts index 2bca5cd3d..7b6d945c2 100644 --- a/src/mcpDoctor.ts +++ b/src/mcpDoctor.ts @@ -105,11 +105,16 @@ const inspectProductionMcpSession = async ( client.listResources(undefined, request), client.listResourceTemplates(undefined, request), client.readResource({ uri: "rea://server/identity" }, request), - client.callTool({ name: "binary_session", arguments: {} }, request), + client.callTool( + { name: "binary_session", arguments: { detail: "full" } }, + request, + ), ]); + const expectedToolNames = + availableToolNames(requestFlow.structuredContent) ?? []; const inventory = { tools: compareInventory( - CATALOG_IDENTITY.tools.map(({ name }) => name), + expectedToolNames, tools.tools.map(({ name }) => name), ), prompts: compareInventory( @@ -177,6 +182,27 @@ const inspectProductionMcpSession = async ( }; }; +const availableToolNames = ( + structuredContent: unknown, +): readonly string[] | undefined => { + if (typeof structuredContent !== "object" || structuredContent === null) + return undefined; + const result = Reflect.get(structuredContent, "result"); + if (typeof result !== "object" || result === null) return undefined; + const availability = Reflect.get(result, "tool_availability"); + if (!Array.isArray(availability)) return undefined; + const names: string[] = []; + for (const item of availability) { + if (typeof item !== "object" || item === null) return undefined; + const name = Reflect.get(item, "name"); + const available = Reflect.get(item, "available"); + if (typeof name !== "string" || typeof available !== "boolean") + return undefined; + if (available) names.push(name); + } + return names; +}; + /** * Dispatcher entry for `rea mcp doctor`, kept separate from Incur MCP setup. * @public diff --git a/src/process/ProcessCommandIdentity.ts b/src/process/ProcessCommandIdentity.ts index 3757f88a6..bb2757d2c 100644 --- a/src/process/ProcessCommandIdentity.ts +++ b/src/process/ProcessCommandIdentity.ts @@ -16,9 +16,13 @@ export const matchesOwnedProcessCommand = ( if (platform !== "darwin") return false; const actualExecutable = actualCommand.split(/\s+/u, 1)[0]; const expectedExecutable = expectedCommand.split(/\s+/u, 1)[0]; + const normalizedActualExecutable = + actualExecutable?.startsWith("(") === true && actualExecutable.endsWith(")") + ? actualExecutable.slice(1, -1) + : actualExecutable; return ( - actualExecutable !== undefined && + normalizedActualExecutable !== undefined && expectedExecutable !== undefined && - basename(actualExecutable) === basename(expectedExecutable) + basename(normalizedActualExecutable) === basename(expectedExecutable) ); }; diff --git a/src/process/ProcessOwnership.ts b/src/process/ProcessOwnership.ts index 0f501234a..7ec7cefcb 100644 --- a/src/process/ProcessOwnership.ts +++ b/src/process/ProcessOwnership.ts @@ -391,6 +391,10 @@ const processOwnershipFailures = async ( if ((await host.environment(member.pid)).REA_PROCESS_RUN_ID !== runId) failures.push({ pid: member.pid, reason: "run-token-mismatch" }); } catch { + try { + const live = liveProcesses(await host.listProcesses()); + if (!live.some(({ pid }) => pid === member.pid)) continue; + } catch {} failures.push({ pid: member.pid, reason: "environment-unreadable" }); } } diff --git a/src/server/DynamicToolAvailability.ts b/src/server/DynamicToolAvailability.ts new file mode 100644 index 000000000..8f93f9932 --- /dev/null +++ b/src/server/DynamicToolAvailability.ts @@ -0,0 +1,98 @@ +import type { McpServer, RegisteredTool } from "@modelcontextprotocol/server"; + +import { + buildCapabilityInventory, + type AvailabilityPolicy, +} from "../application/CapabilityInventory.js"; +import type { BinarySessionPort } from "../application/BinarySession.js"; +import type { ClientFeatureAvailability } from "../contracts/toolOutputSchemaPrimitives.js"; + +interface ToolAvailabilityController { + /** Apply current session, policy, host, provider, and client availability. */ + synchronize(): boolean; +} + +/** + * Track public SDK tool handles and keep their visible state synchronized with + * the same availability projection returned by `binary_session`. + */ +export const installDynamicToolAvailability = ( + server: McpServer, + session: BinarySessionPort, + policy: () => AvailabilityPolicy, +): ToolAvailabilityController => { + const tools = new Map(); + const originalRegisterTool = server.registerTool.bind(server); + const originalSendToolListChanged = server.sendToolListChanged.bind(server); + let synchronizing = false; + const controller: ToolAvailabilityController = { + synchronize: () => { + if (synchronizing) return false; + synchronizing = true; + try { + const availability = new Map( + buildCapabilityInventory( + session.status(), + policy(), + clientFeatures(server), + ).map((item) => [item.name, item.available]), + ); + let changed = false; + for (const [name, tool] of tools) { + const enabled = availability.get(name) === true; + if (tool.enabled === enabled) continue; + changed = true; + if (enabled) tool.enable(); + else tool.disable(); + } + return changed; + } finally { + synchronizing = false; + } + }, + }; + + const wrappedRegisterTool = (...args: unknown[]): unknown => { + const name = args[0]; + if (typeof name !== "string") + throw new TypeError("MCP tool registration requires a string name"); + const registered: unknown = Reflect.apply( + originalRegisterTool, + server, + args, + ); + if (!isRegisteredTool(registered)) + throw new TypeError(`MCP SDK did not return a tool handle for ${name}`); + tools.set(name, registered); + return registered; + }; + if (!Reflect.set(server, "registerTool", wrappedRegisterTool)) + throw new TypeError("Unable to install dynamic MCP tool registration"); + if ( + !Reflect.set(server, "sendToolListChanged", () => { + if (controller.synchronize()) originalSendToolListChanged(); + }) + ) + throw new TypeError("Unable to install dynamic MCP tool notifications"); + + return controller; +}; + +const clientFeatures = (server: McpServer): ClientFeatureAvailability => { + const capabilities = server.server.getClientCapabilities(); + return { + elicitation_form: capabilities?.elicitation?.form !== undefined, + elicitation_url: capabilities?.elicitation?.url !== undefined, + roots: capabilities?.roots !== undefined, + sampling: capabilities?.sampling !== undefined, + }; +}; + +const isRegisteredTool = (value: unknown): value is RegisteredTool => { + if (typeof value !== "object" || value === null) return false; + return ( + typeof Reflect.get(value, "enabled") === "boolean" && + typeof Reflect.get(value, "enable") === "function" && + typeof Reflect.get(value, "disable") === "function" + ); +}; diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 858e8d100..fb6e04635 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -25,14 +25,24 @@ import { registerEvidenceResources } from "./registerEvidenceResources.js"; import { createServerIdentity } from "../serverIdentity.js"; import type { PermissionAuthority } from "../application/PermissionAuthority.js"; import type { BrowserObservationPort } from "../application/BrowserObservationPort.js"; +import type { BrowserScenarioCapturePort } from "../application/BrowserScenarioCapturePort.js"; import { registerBrowserTools } from "./registerBrowserTools.js"; +import { registerBrowserScenarioTool } from "./registerBrowserScenarioTool.js"; import type { ElectronObservationPort } from "../application/ElectronObservationPort.js"; import { registerElectronTools } from "./registerElectronTools.js"; +import type { JavaScriptRuntimeObservationPort } from "../application/JavaScriptRuntimeObservationPort.js"; +import { registerJavaScriptRuntimeObservationTools } from "./registerJavaScriptRuntimeObservationTools.js"; import { registerApplicationTools } from "./registerApplicationTools.js"; import type { SessionAvailability } from "./sessionAvailabilityPolicy.js"; +import { sessionAvailabilityPolicy } from "./sessionAvailabilityPolicy.js"; +import { + DENY_EVIDENCE_FILE_POLICY, + DENY_PROCESS_POLICY, +} from "./sessionToolPolicies.js"; +import { installDynamicToolAvailability } from "./DynamicToolAvailability.js"; const TARGET_FREE_INSTRUCTIONS = - "REA analyzes shipped artifacts. Route: ASAR/JavaScript -> analyze_javascript_application (configured root required); archive/package -> inventory_artifact; managed PE/CLI -> inspect_managed_artifact; browser/Electron runtime -> list_browser_targets/list_electron_targets; native binary/database -> open_binary, then binary_overview. Check binary_session for policy. Start with summaries and cite Evidence IDs. Never repeat identical analysis or read full Evidence without a specific need."; + "REA analyzes shipped artifacts. Route: ASAR/JavaScript -> analyze_javascript_application (configured root); archive/package -> inspect_artifact; managed PE/CLI -> inspect_managed_artifact; passive browser/Electron -> list_browser_targets/list_electron_targets; approved browser interaction -> capture_browser_scenario; Node/Electron Inspector -> list_javascript_runtime_targets; native binary/database -> open_binary, then binary_overview. Check binary_session policy. Start with summaries and cite Evidence IDs. Never repeat identical analysis or read full Evidence without need."; const ACTIVE_TARGET_INSTRUCTIONS = "REA analyzes the active reverse-engineering target. Start native analysis with binary_overview, then narrow with analyze_function, literal search, callers, callees, and xrefs. Prefer summary views, never repeat an identical call, and read full Evidence only when the task requires it."; @@ -57,7 +67,9 @@ export interface CreateServerOptions { readonly analysisSnapshotFilePolicy?: EvidenceFilePolicy; readonly permissionAuthority?: PermissionAuthority; readonly browserObservation?: BrowserObservationPort; + readonly browserScenarioCapture?: BrowserScenarioCapturePort; readonly electronObservation?: ElectronObservationPort; + readonly javascriptRuntimeObservation?: JavaScriptRuntimeObservationPort; readonly artifactIntegrityContinueEnabled?: () => boolean; readonly javascriptReplayPolicy?: JavaScriptReplayPolicy; readonly javascriptReplayHost?: JavaScriptReplayHost; @@ -66,6 +78,32 @@ export interface CreateServerOptions { readonly availabilityPolicy?: () => SessionAvailability; } +const installSessionToolAvailability = ( + server: McpServer, + session: BinarySessionPort | undefined, + options: CreateServerOptions, +) => { + if (session === undefined) return undefined; + const policy = sessionAvailabilityPolicy(options.availabilityPolicy, { + processPolicy: options.processPolicy ?? DENY_PROCESS_POLICY, + evidenceFilePolicy: options.evidenceFilePolicy ?? DENY_EVIDENCE_FILE_POLICY, + investigationInputRoots: options.investigationInputRoots ?? [], + optionalFeatures: { + browserObservationEnabled: options.browserObservation !== undefined, + browserScenarioEnabled: options.browserScenarioCapture !== undefined, + electronObservationEnabled: options.electronObservation !== undefined, + v8InspectorObservationEnabled: + options.javascriptRuntimeObservation !== undefined, + javascriptReplayEnabled: options.javascriptReplayPolicy?.enabled ?? false, + managedRuntimeEnabled: options.managedRuntimePolicy?.enabled ?? false, + }, + }); + return { + policy, + controller: installDynamicToolAvailability(server, session, policy), + }; +}; + /** * Construct one MCP server without acquiring subprocess resources. * Supplying a session adds target lifecycle tools; omitting it retains the @@ -106,9 +144,12 @@ export const createServer = ( : TARGET_FREE_INSTRUCTIONS, }, ); + const dynamicTools = installSessionToolAvailability(server, session, options); server.server.onclose = () => { permissionAuthority?.clearSessionGrants(); }; + if (dynamicTools !== undefined) + server.server.oninitialized = () => server.sendToolListChanged(); session?.onAvailabilityChanged?.(() => server.sendToolListChanged()); registerServerIdentityResource(server, startedAt); const toolLogger = logger.child({ layer: "server" }); @@ -132,6 +173,9 @@ export const createServer = ( registerEvidenceResources(server, session); registerSessionTools(server, session, toolLogger, { ...options, + ...(dynamicTools === undefined + ? {} + : { availabilityPolicy: dynamicTools.policy }), ...(permissionAuthority === undefined ? {} : { permissionAuthority }), startedAt, processCaptureElicitation: { @@ -155,6 +199,7 @@ export const createServer = ( }, }); } + dynamicTools?.controller.synchronize(); return server; }; @@ -310,12 +355,24 @@ const registerObservationTools = ({ permissionAuthority, recordEvidence, }); + registerBrowserScenarioTool(server, { + logger, + provider: options.browserScenarioCapture, + permissionAuthority, + recordEvidence, + }); registerElectronTools(server, { logger, electron: options.electronObservation, permissionAuthority, recordEvidence, }); + registerJavaScriptRuntimeObservationTools(server, { + logger, + runtime: options.javascriptRuntimeObservation, + permissionAuthority, + recordEvidence, + }); registerApplicationTools(server, { logger, recordEvidence, diff --git a/src/server/registerApplicationTools.ts b/src/server/registerApplicationTools.ts index 69f6b06f1..62b8b14c6 100644 --- a/src/server/registerApplicationTools.ts +++ b/src/server/registerApplicationTools.ts @@ -3,10 +3,13 @@ import type { McpServer } from "@modelcontextprotocol/server"; import { registerTraceFeatureTool } from "./registerApplicationTools/traceFeature.js"; import { registerTraceJavaScriptSemanticsTool } from "./registerApplicationTools/traceSemantics.js"; import { registerCompareApplicationVersionsTool } from "./registerApplicationTools/compareVersions.js"; +import { registerCompareSourceToBundleTool } from "./registerApplicationTools/compareSourceToBundle.js"; import { registerCompareJavaScriptExportShapesTool } from "./registerApplicationTools/compareExportShapes.js"; import { registerControlledReplayTool } from "./registerApplicationTools/controlledReplay.js"; import { registerCharacterizationTools } from "./registerApplicationTools/characterization.js"; import { registerCoverageTools } from "./registerApplicationTools/coverage.js"; +import { registerReconstructionObligationLedgerTool } from "./registerApplicationTools/obligationLedger.js"; +import { registerReconstructionReadinessTool } from "./registerApplicationTools/readiness.js"; import type { ApplicationToolRegistration } from "./registerApplicationTools/types.js"; export type { ApplicationToolRegistration }; @@ -19,8 +22,11 @@ export const registerApplicationTools = ( registerTraceFeatureTool(server, options); registerTraceJavaScriptSemanticsTool(server, options); registerCompareApplicationVersionsTool(server, options); + registerCompareSourceToBundleTool(server, options); registerCompareJavaScriptExportShapesTool(server, options); registerControlledReplayTool(server, options); registerCharacterizationTools(server, options); + registerReconstructionObligationLedgerTool(server, options); + registerReconstructionReadinessTool(server, options); registerCoverageTools(server, options); }; diff --git a/src/server/registerApplicationTools/compareSourceToBundle.ts b/src/server/registerApplicationTools/compareSourceToBundle.ts new file mode 100644 index 000000000..67eb20932 --- /dev/null +++ b/src/server/registerApplicationTools/compareSourceToBundle.ts @@ -0,0 +1,59 @@ +import type { McpServer } from "@modelcontextprotocol/server"; + +import { compareSourceToBundleEvidenceValidated } from "../../application/JavaScriptApplicationWorkflowService.js"; +import { resolveCompareSourceToBundleRequestValidated } from "../../application/ApplicationWorkflowEvidenceResolver.js"; +import { applicationToolContract } from "../../contracts/applicationToolContracts.js"; +import { compareSourceToBundleRequestSchema } from "../../contracts/applicationWorkflowInputContracts.js"; +import { sourceToBundleComparisonResultSchema } from "../../domain/sourceToBundleComparisonSchemas.js"; +import { logToolExecution } from "../toolLogging.js"; +import { safeParseToolInput } from "../toolInputValidation.js"; +import { toolRegistrationOptions } from "../toolRegistrationOptions.js"; +import { toCallToolResult } from "../toolResult.js"; +import { recordResult, recordSources } from "./helpers.js"; +import type { ApplicationToolRegistration } from "./types.js"; + +const contract = applicationToolContract("compare_source_to_bundle"); + +/** Register conservative historical-source to bundle comparison. */ +export const registerCompareSourceToBundleTool = ( + server: McpServer, + options: ApplicationToolRegistration, +): void => { + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + async (input) => { + const parsedInput = safeParseToolInput( + compareSourceToBundleRequestSchema, + input, + contract.name, + ); + if (!parsedInput.ok) return toCallToolResult(parsedInput, contract); + const resolved = resolveCompareSourceToBundleRequestValidated( + parsedInput.value, + options.evidenceLookup, + ); + if (!resolved.ok) return toCallToolResult(resolved, contract); + const result = await logToolExecution(options.logger, contract.name, () => + Promise.resolve(compareSourceToBundleEvidenceValidated(resolved.value)), + ); + if (!result.ok) return toCallToolResult(result, contract); + const recorded = recordSources(options.recordEvidence, [ + resolved.value.application, + ]); + if (!recorded.ok) return toCallToolResult(recorded, contract); + const comparison = sourceToBundleComparisonResultSchema.parse( + result.value.normalized_result, + ); + return recordResult( + options, + contract, + result.value, + resolved.value.unknown_registry_approved === true && + comparison.summary.unknown > 0 + ? "source-to-bundle-comparison" + : undefined, + ); + }, + ); +}; diff --git a/src/server/registerApplicationTools/helpers.ts b/src/server/registerApplicationTools/helpers.ts index 6549e7a56..ac63b038b 100644 --- a/src/server/registerApplicationTools/helpers.ts +++ b/src/server/registerApplicationTools/helpers.ts @@ -24,7 +24,10 @@ export const recordResult = ( options: ApplicationToolRegistration, contract: (typeof APPLICATION_TOOL_CONTRACTS)[number], evidence: Evidence, - unknownKind?: "application-version-comparison" | "javascript-export-shape", + unknownKind?: + | "application-version-comparison" + | "javascript-export-shape" + | "source-to-bundle-comparison", ) => { const recorded = unknownKind === undefined @@ -42,13 +45,18 @@ export const recordResult = ( const unknownRegistration = ( evidence: Evidence, - kind: "application-version-comparison" | "javascript-export-shape", + kind: + | "application-version-comparison" + | "javascript-export-shape" + | "source-to-bundle-comparison", ) => ({ approved: true as const, question: kind === "application-version-comparison" ? "Which application entities remain unmatched or ambiguous across these versions?" - : "Which selected JavaScript export return shapes remain dynamic, incomplete, or ambiguously paired?", + : kind === "source-to-bundle-comparison" + ? "Which historical source files remain unmatched or ambiguously mapped to the shipped bundle?" + : "Which selected JavaScript export return shapes remain dynamic, incomplete, or ambiguously paired?", severity: "medium" as const, domain: kind, supporting_evidence_ids: [evidence.evidence_id], @@ -70,13 +78,15 @@ const unknownRegistration = ( "Add approved passive runtime Evidence without promoting it to static fact.", }, ] - : [ - { - operation: "run_controlled_replay", - rationale: - "Validate exact approved module behavior separately when runtime semantics are required.", - }, - ]), + : kind === "source-to-bundle-comparison" + ? [] + : [ + { + operation: "run_controlled_replay", + rationale: + "Validate exact approved module behavior separately when runtime semantics are required.", + }, + ]), ], relationships: [], }); diff --git a/src/server/registerApplicationTools/obligationLedger.ts b/src/server/registerApplicationTools/obligationLedger.ts new file mode 100644 index 000000000..2dd4cb50c --- /dev/null +++ b/src/server/registerApplicationTools/obligationLedger.ts @@ -0,0 +1,53 @@ +import type { McpServer } from "@modelcontextprotocol/server"; + +import { + buildReconstructionObligationLedgerEvidenceValidated, + resolveReconstructionObligationLedgerRequest, +} from "../../application/ReconstructionObligationLedgerService.js"; +import { applicationToolContract } from "../../contracts/applicationToolContracts.js"; +import { reconstructionObligationLedgerInputSchema } from "../../domain/reconstructionObligationLedgerSchemas.js"; +import { logToolExecution } from "../toolLogging.js"; +import { safeParseToolInput } from "../toolInputValidation.js"; +import { toolRegistrationOptions } from "../toolRegistrationOptions.js"; +import { toCallToolResult } from "../toolResult.js"; +import { recordResult, recordSources } from "./helpers.js"; +import type { ApplicationToolRegistration } from "./types.js"; + +const contract = applicationToolContract( + "build_reconstruction_obligation_ledger", +); + +/** Register conservative reconstruction-obligation generation and closure. */ +export const registerReconstructionObligationLedgerTool = ( + server: McpServer, + options: ApplicationToolRegistration, +): void => { + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + async (input) => { + const parsed = safeParseToolInput( + reconstructionObligationLedgerInputSchema, + input, + contract.name, + ); + if (!parsed.ok) return toCallToolResult(parsed, contract); + const resolved = resolveReconstructionObligationLedgerRequest( + parsed.value, + ); + if (!resolved.ok) return toCallToolResult(resolved, contract); + const result = await logToolExecution(options.logger, contract.name, () => + Promise.resolve( + buildReconstructionObligationLedgerEvidenceValidated(resolved.value), + ), + ); + if (!result.ok) return toCallToolResult(result, contract); + const recorded = recordSources( + options.recordEvidence, + resolved.value.evidence_bundle.records, + ); + if (!recorded.ok) return toCallToolResult(recorded, contract); + return recordResult(options, contract, result.value); + }, + ); +}; diff --git a/src/server/registerApplicationTools/readiness.ts b/src/server/registerApplicationTools/readiness.ts new file mode 100644 index 000000000..8d9324d14 --- /dev/null +++ b/src/server/registerApplicationTools/readiness.ts @@ -0,0 +1,79 @@ +import type { McpServer } from "@modelcontextprotocol/server"; + +import { + evaluateReconstructionReadinessValidated, + resolveReconstructionReadinessRequest, +} from "../../application/ReconstructionReadinessService.js"; +import { applicationToolContract } from "../../contracts/applicationToolContracts.js"; +import { reconstructionReadinessInputSchema } from "../../domain/reconstructionReadinessSchemas.js"; +import { reconstructionReadinessReportSchema } from "../../domain/reconstructionReadinessSchemas.js"; +import { logToolExecution } from "../toolLogging.js"; +import { safeParseToolInput } from "../toolInputValidation.js"; +import { toolRegistrationOptions } from "../toolRegistrationOptions.js"; +import { toCallToolResult } from "../toolResult.js"; +import { recordSources } from "./helpers.js"; +import type { ApplicationToolRegistration } from "./types.js"; + +const contract = applicationToolContract("evaluate_reconstruction_readiness"); + +/** Register deterministic public-contract reconstruction conformance. */ +export const registerReconstructionReadinessTool = ( + server: McpServer, + options: ApplicationToolRegistration, +): void => { + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + async (input) => { + const parsed = safeParseToolInput( + reconstructionReadinessInputSchema, + input, + contract.name, + ); + if (!parsed.ok) return toCallToolResult(parsed, contract); + const resolved = resolveReconstructionReadinessRequest(parsed.value); + if (!resolved.ok) return toCallToolResult(resolved, contract); + const result = await logToolExecution(options.logger, contract.name, () => + Promise.resolve( + evaluateReconstructionReadinessValidated(resolved.value), + ), + ); + if (!result.ok) return toCallToolResult(result, contract); + const recorded = recordSources( + options.recordEvidence, + resolved.value.evidence_bundle.records, + ); + if (!recorded.ok) return toCallToolResult(recorded, contract); + const recordedResult = options.recordEvidence?.(result.value); + if (recordedResult !== undefined && !recordedResult.ok) + return toCallToolResult(recordedResult, contract); + const report = reconstructionReadinessReportSchema.parse( + result.value.normalized_result, + ); + const reportUri = `rea://evidence/${result.value.evidence_id}/reconstruction-readiness-report`; + const projection = { + schema: report.schema, + schema_version: report.schema_version, + report_id: report.report_id, + source_digest: report.source_digest, + report_digest: report.report_digest, + status: report.status, + summary: report.summary, + metrics: report.metrics, + report_resource_uri: reportUri, + }; + return toCallToolResult({ ok: true, value: result.value }, contract, { + evidenceResourcesAvailable: recordedResult !== undefined, + evidenceResultProjection: projection, + evidenceTextProjection: projection, + resourceLinks: [ + { + uri: reportUri, + name: report.report_id, + description: "Full deterministic reconstruction readiness report", + }, + ], + }); + }, + ); +}; diff --git a/src/server/registerArtifactTools.ts b/src/server/registerArtifactTools.ts index 2cf7a5844..092ab0c32 100644 --- a/src/server/registerArtifactTools.ts +++ b/src/server/registerArtifactTools.ts @@ -7,6 +7,7 @@ import type { BinaryTarget } from "../domain/binaryTarget.js"; import type { Logger } from "../logger.js"; import { registerEvidenceTools } from "./registerEvidenceTools.js"; import type { PermissionAuthority } from "../application/PermissionAuthority.js"; +import { artifactInspectionResultSchema } from "../domain/artifactInspection.js"; /** Register deterministic artifact inventory and safe extraction operations. */ export const registerArtifactTools = ( @@ -19,5 +20,13 @@ export const registerArtifactTools = ( readonly permissionAuthority?: PermissionAuthority; }, ): void => { - registerEvidenceTools(server, analysis, ARTIFACT_TOOL_CONTRACTS, options); + registerEvidenceTools(server, analysis, ARTIFACT_TOOL_CONTRACTS, { + ...options, + sourceEvidence: (operation, result) => + operation === "inspect_artifact" + ? artifactInspectionResultSchema + .parse(result) + .substeps.map(({ evidence }) => evidence) + : [], + }); }; diff --git a/src/server/registerBrowserScenarioTool.ts b/src/server/registerBrowserScenarioTool.ts new file mode 100644 index 000000000..a14303152 --- /dev/null +++ b/src/server/registerBrowserScenarioTool.ts @@ -0,0 +1,55 @@ +import type { McpServer } from "@modelcontextprotocol/server"; + +import type { BinarySessionPort } from "../application/BinarySession.js"; +import type { BrowserScenarioCapturePort } from "../application/BrowserScenarioCapturePort.js"; +import { captureBrowserScenario } from "../application/BrowserScenarioCaptureService.js"; +import type { PermissionAuthority } from "../application/PermissionAuthority.js"; +import { BROWSER_SCENARIO_TOOL_CONTRACTS } from "../contracts/browserScenarioToolContracts.js"; +import { browserScenarioSchema } from "../domain/browserScenario.js"; +import type { Logger } from "../logger.js"; +import { logToolExecution } from "./toolLogging.js"; +import { safeParseToolInput } from "./toolInputValidation.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; +import { toCallToolResult } from "./toolResult.js"; + +interface BrowserScenarioToolRegistration { + readonly logger: Logger; + readonly provider: BrowserScenarioCapturePort | undefined; + readonly permissionAuthority: PermissionAuthority | undefined; + readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined; +} + +/** Register the controlled browser scenario tool even when policy disables it. */ +export const registerBrowserScenarioTool = ( + server: McpServer, + options: BrowserScenarioToolRegistration, +): void => { + const [contract] = BROWSER_SCENARIO_TOOL_CONTRACTS; + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + async (input, context) => { + const parsed = safeParseToolInput( + browserScenarioSchema, + input, + contract.name, + ); + if (!parsed.ok) return toCallToolResult(parsed, contract); + const result = await logToolExecution(options.logger, contract.name, () => + captureBrowserScenario( + options.provider, + options.permissionAuthority, + parsed.value, + { signal: context.mcpReq.signal }, + ), + ); + if (!result.ok) return toCallToolResult(result, contract); + const recorded = options.recordEvidence?.(result.value); + return recorded !== undefined && !recorded.ok + ? toCallToolResult(recorded, contract) + : toCallToolResult({ ok: true, value: result.value }, contract, { + evidenceResourcesAvailable: recorded !== undefined, + }); + }, + ); +}; diff --git a/src/server/registerBrowserTools.ts b/src/server/registerBrowserTools.ts index b24e384d8..5672307c7 100644 --- a/src/server/registerBrowserTools.ts +++ b/src/server/registerBrowserTools.ts @@ -22,7 +22,7 @@ import { import { analyzeWebBundleInputSchema } from "../domain/webBundleAnalysis.js"; import { observeWebSessionInputSchema } from "../domain/browserSession.js"; import { discoverWebMcpToolsInputSchema } from "../domain/webMcpDiscovery.js"; -import { compareWebCapturesInputSchema } from "../domain/webCaptureDiff.js"; +import { browserCaptureComparisonInputSchema } from "../domain/browserCaptureComparison.js"; import { captureWebScreenshotInputSchema, compareWebScreenshotsInputSchema, @@ -127,7 +127,7 @@ export const registerBrowserTools = ( }); registerBrowserTool(server, options, { contract: captureDiffContract, - schema: compareWebCapturesInputSchema, + schema: browserCaptureComparisonInputSchema, execute: (parsed, _context) => compareWebCaptureEvidence(options.browser, parsed), }); diff --git a/src/server/registerEnhancedTools.ts b/src/server/registerEnhancedTools.ts index 7f51ef21d..fbf19b2bc 100644 --- a/src/server/registerEnhancedTools.ts +++ b/src/server/registerEnhancedTools.ts @@ -229,6 +229,18 @@ const ENHANCED_INPUT_PARSERS: { input, "trace_feature", ), + find_code_for_string: (input) => + safeParseToolInput( + enhancedInputSchemas.find_code_for_string, + input, + "find_code_for_string", + ), + trace_call_path: (input) => + safeParseToolInput( + enhancedInputSchemas.trace_call_path, + input, + "trace_call_path", + ), }; const parseEnhancedCall = ( @@ -286,6 +298,16 @@ const parseEnhancedCall = ( ENHANCED_INPUT_PARSERS.trace_feature(input), (value) => ({ name, input: value }), ); + case "find_code_for_string": + return mapEnhancedCall( + ENHANCED_INPUT_PARSERS.find_code_for_string(input), + (value) => ({ name, input: value }), + ); + case "trace_call_path": + return mapEnhancedCall( + ENHANCED_INPUT_PARSERS.trace_call_path(input), + (value) => ({ name, input: value }), + ); } }; @@ -313,7 +335,9 @@ const recordWorkflowUnknowns = ({ | ReturnType | { readonly ok: true; readonly value: null } => { if ( - name !== "trace_feature" || + !["trace_feature", "find_code_for_string", "trace_call_path"].includes( + name, + ) || input.unknown_registry_approved !== true || recordUnknown === undefined || typeof result !== "object" || @@ -336,7 +360,7 @@ const recordWorkflowUnknowns = ({ required_environment: null, recommended_probes: [ { - operation: "trace_feature", + operation: name, rationale: "Continue with a larger bounded budget.", }, ], diff --git a/src/server/registerEvidenceResources.ts b/src/server/registerEvidenceResources.ts index 8bd8a9638..4b33740d1 100644 --- a/src/server/registerEvidenceResources.ts +++ b/src/server/registerEvidenceResources.ts @@ -10,6 +10,8 @@ import canonicalize from "canonicalize"; import type { BinarySessionPort } from "../application/BinarySessionPort.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { registerJavaScriptApplicationGraphResource } from "./registerJavaScriptApplicationGraphResource.js"; +import { registerReconstructionObligationLedgerResource } from "./registerReconstructionObligationLedgerResource.js"; +import { registerReconstructionReadinessResource } from "./registerReconstructionReadinessResource.js"; const evidenceUri = (evidenceId: string): string => `rea://evidence/${evidenceId}`; @@ -27,6 +29,8 @@ export const registerEvidenceResources = ( registerArtifactPageResource(server, session); registerFunctionDossierResource(server, session); registerEvidenceSectionResource(server, session); + registerReconstructionObligationLedgerResource(server, session); + registerReconstructionReadinessResource(server, session); registerJavaScriptApplicationGraphResource(server, session); registerResidualUnknownResource(server, session); }; diff --git a/src/server/registerEvidenceTools.ts b/src/server/registerEvidenceTools.ts index f53bdd19c..7bc215bd6 100644 --- a/src/server/registerEvidenceTools.ts +++ b/src/server/registerEvidenceTools.ts @@ -7,7 +7,7 @@ import type { import type { BinarySessionPort } from "../application/BinarySession.js"; import type { ToolContract } from "../contracts/toolContracts.js"; import type { BinaryTarget } from "../domain/binaryTarget.js"; -import { createEvidence } from "../domain/evidence.js"; +import { createEvidence, type Evidence } from "../domain/evidence.js"; import { jsonObjectSchema } from "../domain/jsonValue.js"; import type { Logger } from "../logger.js"; import { logToolExecution } from "./toolLogging.js"; @@ -27,6 +27,10 @@ interface EvidenceToolRegistration { readonly activeTarget: (() => BinaryTarget | undefined) | undefined; readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined; readonly permissionAuthority?: PermissionAuthority; + readonly sourceEvidence?: ( + operation: Exclude, + result: import("../domain/jsonValue.js").JsonValue, + ) => readonly Evidence[]; } /** Register provider-backed contracts that return atomic Evidence v2 observations. */ @@ -92,6 +96,8 @@ export const registerEvidenceTools = ( terminal: true, }); if (!execution.ok) return toCallToolResult(execution, contract); + const sourceEvidence = + options.sourceEvidence?.(contract.name, execution.value.result) ?? []; const evidence = createEvidence( execution.value.subject ?? options.activeTarget?.(), execution.value.provider, @@ -102,8 +108,14 @@ export const registerEvidenceTools = ( rawResult: execution.value.rawResult, limitations: execution.value.limitations, locations: execution.value.locations, + evidenceLinks: sourceEvidence.map(({ evidence_id: id }) => id), }, ); + for (const source of sourceEvidence) { + const sourceRecorded = options.recordEvidence?.(source); + if (sourceRecorded !== undefined && !sourceRecorded.ok) + return toCallToolResult(sourceRecorded, contract); + } const recorded = options.recordEvidence?.(evidence); return recorded !== undefined && !recorded.ok ? toCallToolResult(recorded, contract) @@ -135,7 +147,7 @@ const permissionRequest = ( operation_identity: `extract_artifact:${parameters.output_root}`, }; if ( - operation === "inventory_artifact" && + ["inventory_artifact", "inspect_artifact"].includes(operation) && parameters.native_mount_approved === true ) return { @@ -145,7 +157,7 @@ const permissionRequest = ( environment_names: [], network: "none" as const, mount: true, - operation_identity: "inventory_artifact:native_mount", + operation_identity: `${operation}:native_mount`, }; return undefined; }; diff --git a/src/server/registerJavaScriptRuntimeObservationTools.ts b/src/server/registerJavaScriptRuntimeObservationTools.ts new file mode 100644 index 000000000..ea6a7910e --- /dev/null +++ b/src/server/registerJavaScriptRuntimeObservationTools.ts @@ -0,0 +1,98 @@ +import type { McpServer } from "@modelcontextprotocol/server"; +import type { z } from "zod"; + +import type { BinarySessionPort } from "../application/BinarySession.js"; +import type { JavaScriptRuntimeObservationPort } from "../application/JavaScriptRuntimeObservationPort.js"; +import { + listJavaScriptRuntimeTargets, + observeJavaScriptRuntime, +} from "../application/JavaScriptRuntimeObservationService.js"; +import type { PermissionAuthority } from "../application/PermissionAuthority.js"; +import { JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS } from "../contracts/javascriptRuntimeObservationToolContracts.js"; +import type { ToolContract } from "../contracts/toolContracts.js"; +import type { AnalysisError } from "../domain/errors.js"; +import type { Evidence } from "../domain/evidence.js"; +import { + listJavaScriptRuntimeTargetsInputSchema, + observeJavaScriptRuntimeInputSchema, +} from "../domain/javascriptRuntimeObservation.js"; +import type { Result } from "../domain/result.js"; +import type { Logger } from "../logger.js"; +import { logToolExecution } from "./toolLogging.js"; +import { safeParseToolInput } from "./toolInputValidation.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; +import { toCallToolResult } from "./toolResult.js"; + +interface RuntimeToolRegistration { + readonly logger: Logger; + readonly runtime: JavaScriptRuntimeObservationPort | undefined; + readonly permissionAuthority: PermissionAuthority | undefined; + readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined; +} + +interface RuntimeToolSpec { + readonly contract: ToolContract; + readonly schema: Schema; + readonly execute: ( + parsed: z.output, + signal: AbortSignal, + ) => Promise>; +} + +/** Register passive Inspector tools even when policy keeps them unavailable. */ +export const registerJavaScriptRuntimeObservationTools = ( + server: McpServer, + options: RuntimeToolRegistration, +): void => { + const [listContract, observeContract] = + JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS; + registerRuntimeTool(server, options, { + contract: listContract, + schema: listJavaScriptRuntimeTargetsInputSchema, + execute: (parsed, signal) => + listJavaScriptRuntimeTargets( + options.runtime, + options.permissionAuthority, + parsed, + { signal }, + ), + }); + registerRuntimeTool(server, options, { + contract: observeContract, + schema: observeJavaScriptRuntimeInputSchema, + execute: (parsed, signal) => + observeJavaScriptRuntime( + options.runtime, + options.permissionAuthority, + parsed, + { signal }, + ), + }); +}; + +const registerRuntimeTool = ( + server: McpServer, + options: RuntimeToolRegistration, + spec: RuntimeToolSpec, +): void => { + server.registerTool( + spec.contract.name, + toolRegistrationOptions(spec.contract), + async (input, context) => { + const parsed = safeParseToolInput(spec.schema, input, spec.contract.name); + if (!parsed.ok) return toCallToolResult(parsed, spec.contract); + const result = await logToolExecution( + options.logger, + spec.contract.name, + () => spec.execute(parsed.value, context.mcpReq.signal), + ); + if (!result.ok) return toCallToolResult(result, spec.contract); + const recorded = options.recordEvidence?.(result.value); + return recorded !== undefined && !recorded.ok + ? toCallToolResult(recorded, spec.contract) + : toCallToolResult({ ok: true, value: result.value }, spec.contract, { + evidenceResourcesAvailable: recorded !== undefined, + }); + }, + ); +}; diff --git a/src/server/registerReconstructionObligationLedgerResource.ts b/src/server/registerReconstructionObligationLedgerResource.ts new file mode 100644 index 000000000..42114bd38 --- /dev/null +++ b/src/server/registerReconstructionObligationLedgerResource.ts @@ -0,0 +1,69 @@ +import { + ResourceNotFoundError, + ResourceTemplate, + type McpServer, +} from "@modelcontextprotocol/server"; + +import type { BinarySessionPort } from "../application/BinarySessionPort.js"; +import { reconstructionObligationLedgerPageSchema } from "../domain/reconstructionObligationLedgerSchemas.js"; + +/** Expose retained obligation-ledger pages as typed Evidence resources. */ +export const registerReconstructionObligationLedgerResource = ( + server: McpServer, + session: BinarySessionPort, +): void => { + server.registerResource( + "reconstruction-obligation-ledger-page", + new ResourceTemplate( + "rea://evidence/{evidenceId}/reconstruction-obligation-ledger", + { + list: undefined, + complete: { + evidenceId: (prefix) => + session + .exportEvidenceBundle() + .records.filter( + ({ predicate_type: predicateType }) => + predicateType === "rea.reconstruction-obligation-ledger/v1", + ) + .map(({ evidence_id: evidenceId }) => evidenceId) + .filter((evidenceId) => evidenceId.startsWith(prefix)), + }, + }, + ), + { + title: "Reconstruction obligation ledger page", + description: + "One deterministic Evidence v2-backed obligation page with full-ledger closure digest and summaries.", + mimeType: "application/json", + }, + (uri, variables) => { + const evidenceId = variables.evidenceId; + if (typeof evidenceId !== "string") + throw new ResourceNotFoundError(uri.href); + const evidence = session.evidenceById(evidenceId); + if ( + evidence === undefined || + evidence.predicate_type !== "rea.reconstruction-obligation-ledger/v1" + ) + throw new ResourceNotFoundError(uri.href); + const page = reconstructionObligationLedgerPageSchema.safeParse( + evidence.normalized_result, + ); + if (!page.success) throw new ResourceNotFoundError(uri.href); + return { + contents: [ + { + uri: uri.href, + mimeType: "application/json", + text: JSON.stringify( + { evidence_id: evidence.evidence_id, page: page.data }, + null, + 2, + ), + }, + ], + }; + }, + ); +}; diff --git a/src/server/registerReconstructionReadinessResource.ts b/src/server/registerReconstructionReadinessResource.ts new file mode 100644 index 000000000..b965a6f85 --- /dev/null +++ b/src/server/registerReconstructionReadinessResource.ts @@ -0,0 +1,69 @@ +import { + ResourceNotFoundError, + ResourceTemplate, + type McpServer, +} from "@modelcontextprotocol/server"; + +import type { BinarySessionPort } from "../application/BinarySessionPort.js"; +import { reconstructionReadinessReportSchema } from "../domain/reconstructionReadinessSchemas.js"; + +/** Expose retained full readiness reports outside compact tool responses. */ +export const registerReconstructionReadinessResource = ( + server: McpServer, + session: BinarySessionPort, +): void => { + server.registerResource( + "reconstruction-readiness-report", + new ResourceTemplate( + "rea://evidence/{evidenceId}/reconstruction-readiness-report", + { + list: undefined, + complete: { + evidenceId: (prefix) => + session + .exportEvidenceBundle() + .records.filter( + ({ predicate_type: predicateType }) => + predicateType === "rea.reconstruction-readiness-report/v1", + ) + .map(({ evidence_id: evidenceId }) => evidenceId) + .filter((evidenceId) => evidenceId.startsWith(prefix)), + }, + }, + ), + { + title: "Reconstruction readiness report", + description: + "Full deterministic nine-stage conformance report and replay identity.", + mimeType: "application/json", + }, + (uri, variables) => { + const evidenceId = variables.evidenceId; + if (typeof evidenceId !== "string") + throw new ResourceNotFoundError(uri.href); + const evidence = session.evidenceById(evidenceId); + if ( + evidence === undefined || + evidence.predicate_type !== "rea.reconstruction-readiness-report/v1" + ) + throw new ResourceNotFoundError(uri.href); + const report = reconstructionReadinessReportSchema.safeParse( + evidence.normalized_result, + ); + if (!report.success) throw new ResourceNotFoundError(uri.href); + return { + contents: [ + { + uri: uri.href, + mimeType: "application/json", + text: JSON.stringify( + { evidence_id: evidence.evidence_id, report: report.data }, + null, + 2, + ), + }, + ], + }; + }, + ); +}; diff --git a/src/server/registerSessionStatusTool.ts b/src/server/registerSessionStatusTool.ts index 3b451c612..62083b247 100644 --- a/src/server/registerSessionStatusTool.ts +++ b/src/server/registerSessionStatusTool.ts @@ -10,6 +10,7 @@ import { toCallToolResult } from "./toolResult.js"; import { jsonObjectSchema } from "../domain/jsonValue.js"; import { createServerIdentity } from "../serverIdentity.js"; import { buildCapabilityInventory } from "../application/CapabilityInventory.js"; +import type { ClientFeatureAvailability } from "../contracts/toolOutputSchemaPrimitives.js"; import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; import { safeParseToolInput } from "./toolInputValidation.js"; import type { SessionAvailability } from "./sessionAvailabilityPolicy.js"; @@ -44,11 +45,18 @@ export const registerSessionStatusTool = ( const parsed = parsedInput.value; const client = server.server.getClientVersion(); const clientCapabilities = server.server.getClientCapabilities(); + const clientFeatures: ClientFeatureAvailability = { + elicitation_form: clientCapabilities?.elicitation?.form !== undefined, + elicitation_url: clientCapabilities?.elicitation?.url !== undefined, + roots: clientCapabilities?.roots !== undefined, + sampling: clientCapabilities?.sampling !== undefined, + }; const status = session.status(); const statusObject = jsonObjectSchema.parse(status); const toolAvailability = buildCapabilityInventory( status, availabilityPolicy(), + clientFeatures, ); const serverIdentity = createServerIdentity({ startedAt, @@ -78,14 +86,7 @@ export const registerSessionStatusTool = ( status: statusObject, toolAvailability, serverIdentity, - clientFeatures: { - elicitation_form: - clientCapabilities?.elicitation?.form !== undefined, - elicitation_url: - clientCapabilities?.elicitation?.url !== undefined, - roots: clientCapabilities?.roots !== undefined, - sampling: clientCapabilities?.sampling !== undefined, - }, + clientFeatures, }), }, contract, @@ -101,7 +102,7 @@ const projectSessionStatus = (options: { >; readonly toolAvailability: readonly ToolAvailability[]; readonly serverIdentity: ReturnType; - readonly clientFeatures: Readonly>; + readonly clientFeatures: ClientFeatureAvailability; }) => { if (options.input.detail === "full") return jsonObjectSchema.parse({ @@ -139,6 +140,7 @@ const projectSessionStatus = (options: { next_cursor: nextCursor < filtered.length ? nextCursor : null, has_more: nextCursor < filtered.length, }, + client_features: options.clientFeatures, }); } return jsonObjectSchema.parse({ @@ -182,7 +184,7 @@ const recommendedActions = ( : []), ...(!open ? [ - "Route the supplied target directly: ASAR/JavaScript to analyze_javascript_application, packages to inventory_artifact, managed PE/CLI to inspect_managed_artifact, browser/Electron runtimes to their list-target tools, and native binaries to open_binary.", + "Route the supplied target directly: ASAR/JavaScript to analyze_javascript_application, unfamiliar packages to inspect_artifact, managed PE/CLI to inspect_managed_artifact, browser/Electron runtimes to their list-target tools, and native binaries to open_binary.", ] : [ "Continue from the active target with binary_overview or a bounded target-specific query.", diff --git a/src/server/registerSessionTools.ts b/src/server/registerSessionTools.ts index 2c4231cfc..311e701d9 100644 --- a/src/server/registerSessionTools.ts +++ b/src/server/registerSessionTools.ts @@ -351,12 +351,11 @@ export const registerSessionTools = ( contracts: [openContract, closeContract, statusContract], snapshotFilePolicy: analysisSnapshotFilePolicy, startedAt: options.startedAt ?? new Date().toISOString(), - availabilityPolicy: sessionAvailabilityPolicy( - options.availabilityPolicy, + availabilityPolicy: sessionAvailabilityPolicy(options.availabilityPolicy, { processPolicy, evidenceFilePolicy, - options.investigationInputRoots ?? [], - ), + investigationInputRoots: options.investigationInputRoots ?? [], + }), ...(options.permissionAuthority === undefined ? {} : { permissionAuthority: options.permissionAuthority }), diff --git a/src/server/sessionAvailabilityPolicy.ts b/src/server/sessionAvailabilityPolicy.ts index 75837dfb4..c4849c641 100644 --- a/src/server/sessionAvailabilityPolicy.ts +++ b/src/server/sessionAvailabilityPolicy.ts @@ -1,30 +1,44 @@ +import type { AvailabilityPolicy } from "../application/CapabilityInventory.js"; import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js"; import type { ProcessExecutionPolicy } from "../domain/processCapture.js"; -export interface SessionAvailability { - readonly processCaptureEnabled: boolean; - readonly evidenceFileRoots: number; - readonly investigationInputRoots: number; - readonly browserObservationEnabled?: boolean; - readonly electronObservationEnabled?: boolean; - readonly javascriptReplayEnabled?: boolean; - readonly managedRuntimeEnabled?: boolean; +export type SessionAvailability = AvailabilityPolicy; + +export interface SessionAvailabilityDefaults { + readonly processPolicy: ProcessExecutionPolicy; + readonly evidenceFilePolicy: EvidenceFilePolicy; + readonly investigationInputRoots: readonly string[]; + readonly optionalFeatures?: Pick< + SessionAvailability, + | "browserObservationEnabled" + | "browserScenarioEnabled" + | "electronObservationEnabled" + | "v8InspectorObservationEnabled" + | "javascriptReplayEnabled" + | "managedRuntimeEnabled" + >; } /** Select configured availability reporting or the target-free defaults. */ export const sessionAvailabilityPolicy = ( configured: (() => SessionAvailability) | undefined, - processPolicy: ProcessExecutionPolicy, - evidenceFilePolicy: EvidenceFilePolicy, - investigationInputRoots: readonly string[], + defaults: SessionAvailabilityDefaults, ): (() => SessionAvailability) => configured ?? (() => ({ - processCaptureEnabled: processPolicy.enabled, - evidenceFileRoots: evidenceFilePolicy.roots.length, - investigationInputRoots: investigationInputRoots.length, - browserObservationEnabled: false, - electronObservationEnabled: false, - javascriptReplayEnabled: false, - managedRuntimeEnabled: false, + processCaptureEnabled: defaults.processPolicy.enabled, + evidenceFileRoots: defaults.evidenceFilePolicy.roots.length, + investigationInputRoots: defaults.investigationInputRoots.length, + browserObservationEnabled: + defaults.optionalFeatures?.browserObservationEnabled ?? false, + browserScenarioEnabled: + defaults.optionalFeatures?.browserScenarioEnabled ?? false, + electronObservationEnabled: + defaults.optionalFeatures?.electronObservationEnabled ?? false, + v8InspectorObservationEnabled: + defaults.optionalFeatures?.v8InspectorObservationEnabled ?? false, + javascriptReplayEnabled: + defaults.optionalFeatures?.javascriptReplayEnabled ?? false, + managedRuntimeEnabled: + defaults.optionalFeatures?.managedRuntimeEnabled ?? false, })); diff --git a/tests/applicationWorkflowCli.test.ts b/tests/applicationWorkflowCli.test.ts index c6f1abd30..d7bbb05e3 100644 --- a/tests/applicationWorkflowCli.test.ts +++ b/tests/applicationWorkflowCli.test.ts @@ -12,6 +12,7 @@ import { JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE, JAVASCRIPT_FEATURE_TRACE_EXAMPLE, JAVASCRIPT_VERSION_COMPARISON_FULL_EVIDENCE_EXAMPLE, + SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE, } from "../src/contracts/javascriptApplicationWorkflowExamples.js"; import { createEvidenceBundle, @@ -65,6 +66,18 @@ describe("application workflow CLI parity", () => { summary: { unknown: expect.any(Number) }, }, }); + const sourceCompared = await runCli([ + "compare-source-to-bundle", + JSON.stringify({ + reference: SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE.reference, + application: JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE.application, + }), + "--json", + ]); + expect(sourceCompared).toMatchObject({ + operation: "compare_source_to_bundle", + normalized_result: { schema_version: 1 }, + }); }, 20_000); it("traces the same authenticated semantic graph through the CLI", async () => { diff --git a/tests/applicationWorkflowMcp.test.ts b/tests/applicationWorkflowMcp.test.ts index 3c4a5d8d7..d99256c0c 100644 --- a/tests/applicationWorkflowMcp.test.ts +++ b/tests/applicationWorkflowMcp.test.ts @@ -12,6 +12,7 @@ import { JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE, JAVASCRIPT_FEATURE_TRACE_EXAMPLE, JAVASCRIPT_VERSION_COMPARISON_FULL_EVIDENCE_EXAMPLE, + SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE, } from "../src/contracts/javascriptApplicationWorkflowExamples.js"; import { createEvidence } from "../src/domain/evidence.js"; import { createServer } from "../src/server/createServer.js"; @@ -202,6 +203,25 @@ describe("application workflow MCP parity", () => { coverage: { status: expect.any(String) }, }, }); + const sourceCompared = await client.callTool({ + name: "compare_source_to_bundle", + arguments: { + reference: SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE.reference, + application: + JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE.application, + }, + }); + expect(sourceCompared.isError).not.toBe(true); + expect(sourceCompared.structuredContent).toMatchObject({ + result: { + schema_version: 1, + reference: { + root_sha256: + SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE.reference.root_sha256, + }, + scoring: { algorithm: "rea-source-to-bundle-signals/v1" }, + }, + }); expect(session.exportEvidenceBundle().records.length).toBeGreaterThan(2); await new Promise((resolve) => setImmediate(resolve)); @@ -243,6 +263,16 @@ describe("application workflow MCP parity", () => { }, }, }); + const sourceComparedById = await client.callTool({ + name: "compare_source_to_bundle", + arguments: { + ...SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE, + application_evidence_id: + JAVASCRIPT_FEATURE_TRACE_FULL_EVIDENCE_EXAMPLE.application + .evidence_id, + }, + }); + expect(sourceComparedById.isError).not.toBe(true); const missing = await client.callTool({ name: "trace_application_feature", diff --git a/tests/artifactMcp.test.ts b/tests/artifactMcp.test.ts index 230f01446..8a030ee84 100644 --- a/tests/artifactMcp.test.ts +++ b/tests/artifactMcp.test.ts @@ -196,6 +196,26 @@ describe("artifact graph MCP integration", () => { }), ]), ); + const inspected = await client.callTool({ + name: "inspect_artifact", + arguments: { + integrity_policy: "record-and-continue", + integrity_continue_approved: true, + }, + }); + expect(inspected.isError).not.toBe(true); + expect(compactResult(inspected.structuredContent).result).toMatchObject({ + contradictions: [ + expect.objectContaining({ occurrence_id: expect.any(String) }), + expect.objectContaining({ occurrence_id: expect.any(String) }), + ], + substeps: [ + expect.objectContaining({ + operation: "inventory_artifact", + status: "completed", + }), + ], + }); const compared = await client.callTool({ name: "compare_artifacts", arguments: { @@ -326,6 +346,44 @@ describe("artifact graph MCP integration", () => { arguments: { path: archive }, }); expect(opened.isError).not.toBe(true); + const inspected = await client.callTool({ + name: "inspect_artifact", + arguments: { max_observations: 1, max_relationships: 1 }, + }); + expect(inspected.isError).not.toBe(true); + const inspectionResult = compactResult(inspected.structuredContent); + const inspectionEvidence = session.evidenceById( + inspectionResult.evidence_id, + ); + if (inspectionEvidence === undefined) + throw new Error("missing inspection Evidence"); + const inspection = z + .object({ + substeps: z.array( + z.object({ + evidence_id: z.string(), + status: z.literal("completed"), + }), + ), + coverage: z.object({ + status: z.literal("truncated"), + substeps_completed: z.literal(1), + }), + unexplored_branches: z.array(z.object({ reason: z.string() })), + }) + .parse(inspectionResult.result); + expect(inspection.substeps).toHaveLength(1); + expect( + session.evidenceById(inspection.substeps[0]?.evidence_id ?? ""), + ).toMatchObject({ operation: "inventory_artifact" }); + expect(inspectionEvidence.evidence_links).toEqual([ + inspection.substeps[0]?.evidence_id, + ]); + expect(inspection.unexplored_branches).toEqual( + expect.arrayContaining([ + expect.objectContaining({ reason: "page-limit" }), + ]), + ); const inventory = await client.callTool({ name: "inventory_artifact", arguments: {}, @@ -381,7 +439,7 @@ describe("artifact graph MCP integration", () => { const envelope = z .object({ result: evidenceBundleSchema }) .parse(exported.structuredContent); - expect(envelope.result.records).toHaveLength(4); + expect(envelope.result.records).toHaveLength(6); expect(envelope.result.artifacts).toContainEqual({ digest: { sha256: evidence.subject?.digest.sha256 }, format: "ipa", diff --git a/tests/artifactProvider.test.ts b/tests/artifactProvider.test.ts index 0a72c5307..d3852aebb 100644 --- a/tests/artifactProvider.test.ts +++ b/tests/artifactProvider.test.ts @@ -23,6 +23,7 @@ import { } from "../src/artifacts/ArtifactPaths.js"; import { artifactExtractionInputSchema, + artifactInspectionInputSchema, artifactInventoryInputSchema, } from "../src/contracts/artifactToolContracts.js"; import type { BinaryTarget } from "../src/domain/binaryTarget.js"; @@ -34,6 +35,7 @@ import { artifactExtractionResultSchema, artifactInventoryResultSchema, } from "../src/domain/artifactGraph.js"; +import { artifactInspectionResultSchema } from "../src/domain/artifactInspection.js"; describe("artifact graph provider", () => { it("inventories app trees deterministically without following symlinks", async () => { @@ -124,6 +126,28 @@ describe("artifact graph provider", () => { provider: { id: "rea-artifact-graph" }, subject: { format: "apk" }, }); + const inspectionEvidence = parseEvidence( + await runProviderAnalysis(zipPath, "inspect_artifact", { + max_observations: 2, + max_relationships: 1, + }), + ); + const inspection = artifactInspectionResultSchema.parse( + inspectionEvidence.normalized_result, + ); + expect(inspection).toMatchObject({ + substeps: [ + { + operation: "inventory_artifact", + status: "completed", + evidence_id: expect.stringMatching(/^ev_[a-f0-9]{64}$/u), + }, + ], + coverage: { status: "truncated", substeps_completed: 1 }, + }); + expect(inspectionEvidence.evidence_links).toEqual( + inspection.evidence_links, + ); const cancellation = new AbortController(); cancellation.abort(); await expect( @@ -135,6 +159,15 @@ describe("artifact graph provider", () => { cancellation.signal, ), ).resolves.toMatchObject({ code: "cancelled" }); + await expect( + runProviderAnalysis( + zipPath, + "inspect_artifact", + {}, + undefined, + cancellation.signal, + ), + ).resolves.toMatchObject({ code: "cancelled" }); const source = join(root, "asar-source"); await mkdir(source); @@ -177,6 +210,13 @@ describe("artifact graph provider", () => { }, }, }); + const failedInspection = await new ArtifactProvider() + .createClient(target(unpackedPath, "asar")) + .execute("inspect_artifact", artifactInspectionInputSchema.parse({})); + expect(failedInspection).toMatchObject({ + ok: false, + error: { _tag: "ArtifactOperationError", reason: "integrity" }, + }); }); it.each([ diff --git a/tests/browserCli.test.ts b/tests/browserCli.test.ts index 36880527a..47f9bc8c2 100644 --- a/tests/browserCli.test.ts +++ b/tests/browserCli.test.ts @@ -10,7 +10,7 @@ import { const execute = promisify(execFile); const browsers: FakeCdpBrowser[] = []; -const INTEGRATION_TEST_TIMEOUT_MS = 40_000; +const INTEGRATION_TEST_TIMEOUT_MS = 60_000; afterEach(async () => { await Promise.all(browsers.splice(0).map(async (browser) => browser.close())); @@ -97,6 +97,26 @@ describe("browser CLI parity", () => { }, }, }); + const scenarioCapture = completeScenarioCapture(); + const scenarioCompared = await runCli( + [ + "compare-web-captures", + JSON.stringify(scenarioCapture), + JSON.stringify(scenarioCapture), + "--normalization-json", + '{"rules":[]}', + "--json", + ], + environment, + ); + expect(scenarioCompared).toMatchObject({ + operation: "compare_web_captures", + normalized_result: { + comparison_kind: "browser_scenario", + overall_status: "unchanged", + alignment: { status: "aligned", aligned_steps: 2 }, + }, + }); const analyzed = await runCli( [ "analyze-web-bundle", @@ -277,5 +297,66 @@ const normalizedResult = (value: unknown): Record => { return value.normalized_result; }; +const completeScenarioCapture = () => { + const url = { + url: "https://app.example.test/", + origin: "https://app.example.test", + query_parameter_names: [], + redacted: false, + }; + const completeness = { + status: "complete", + equality_eligible: true, + missing_sections: [], + truncated_sections: [], + }; + const artifacts = { + screenshot: { state: "not_requested" }, + dom: { state: "not_requested" }, + accessibility: { state: "not_requested" }, + url: { state: "captured", value: url }, + history: { state: "not_requested" }, + storage: { state: "not_requested" }, + }; + const step = (stepIndex: number, stepId: string, action: string) => ({ + step_index: stepIndex, + step_id: stepId, + action, + status: "completed", + elapsed_ms: stepIndex, + before_url: url, + after_url: url, + error: null, + event_sequence_start: 1, + event_sequence_end: 0, + artifacts, + completeness, + }); + return { + schema_version: 1, + browser: { + mode: "connect", + process_ownership: "external", + cleanup: "disconnected-external", + product: "Chromium", + version: "149", + }, + scenario: { + start_origin: "https://app.example.test", + allowed_origins: ["https://app.example.test"], + action_count: 1, + secret_references: [], + }, + duration_ms: 1, + steps: [ + step(0, "scenario_start", "scenario_start"), + step(1, "open-settings", "click"), + ], + events: { retained: 0, dropped: 0, items: [] }, + completeness, + limitations: [], + }; +}; + const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); diff --git a/tests/browserMcp.test.ts b/tests/browserMcp.test.ts index 3db45eba6..8edabb9aa 100644 --- a/tests/browserMcp.test.ts +++ b/tests/browserMcp.test.ts @@ -38,7 +38,7 @@ describe("browser MCP tools", () => { const connected = await connectBrowser(browser); const tools = await connected.client.listTools(); - expect(tools.tools).toHaveLength(TOOL_CONTRACTS.length); + expect(tools.tools.length).toBeLessThan(TOOL_CONTRACTS.length); expect(tools.tools.map(({ name }) => name)).toEqual( expect.arrayContaining([ "list_browser_targets", @@ -49,8 +49,6 @@ describe("browser MCP tools", () => { "compare_web_captures", "capture_web_screenshot", "compare_web_screenshots", - "list_electron_targets", - "inspect_electron_page", ]), ); const status = await connected.client.callTool({ diff --git a/tests/browserObservation.test.ts b/tests/browserObservation.test.ts index 226b87a6d..cf9aa85c8 100644 --- a/tests/browserObservation.test.ts +++ b/tests/browserObservation.test.ts @@ -167,6 +167,7 @@ describe("browser observation contracts", () => { { include_console_text: true }, { include_json_body_shapes: true }, { include_websocket_shapes: true }, + { include_storage_fingerprints: true }, ]) expect( inspectWebPageInputSchema.safeParse({ ...base, ...input }).success, diff --git a/tests/browserScenario.test.ts b/tests/browserScenario.test.ts new file mode 100644 index 000000000..1b12e9a02 --- /dev/null +++ b/tests/browserScenario.test.ts @@ -0,0 +1,308 @@ +import { describe, expect, it } from "vitest"; + +import { BrowserScenarioSecrets } from "../src/browser/BrowserScenarioSecrets.js"; +import { browserScenarioSchema } from "../src/domain/browserScenario.js"; + +const secret = (secret_id: string) => ({ source: "secret", secret_id }); +const literal = (value: string) => ({ + source: "literal", + value, + classification: "public", +}); + +const baseScenario = () => ({ + schema_version: 1, + browser: { + mode: "launch", + executable_path: "/opt/chromium/chrome", + headless: true, + user_data: "temporary-owned", + cleanup: "close-and-delete-profile", + }, + start_url: { + url: "https://app.example.test/", + query: [{ name: "token", value: secret("session") }], + }, + allowed_origins: ["https://api.example.test", "https://app.example.test"], + environment: { + viewport: { width: 1_280, height: 720 }, + locale: "en-US", + timezone: "UTC", + color_scheme: "dark", + reduced_motion: "reduce", + service_workers: "block", + }, + actions: [ + { + step_id: "login", + action: "fill", + locator: { kind: "test_id", value: "password" }, + value: secret("login_input"), + }, + { + step_id: "submit", + action: "click", + locator: { + kind: "role", + role: "button", + name: "Sign in", + exact: true, + }, + }, + { + step_id: "dashboard", + action: "goto", + destination: { url: "https://app.example.test/dashboard" }, + wait_until: "load", + }, + ], + storage: { + cookies: [ + { + name: "sid", + value: secret("cookie"), + destination: { url: "https://app.example.test/" }, + http_only: true, + secure: true, + same_site: "Lax", + }, + ], + local_storage: [ + { + origin: "https://app.example.test", + entries: [{ name: "theme", value: literal("dark") }], + }, + ], + }, + request_replay: { + mode: "exact", + unmatched: "abort", + routes: [ + { + route_id: "profile", + method: "GET", + request: { url: "https://api.example.test/profile" }, + response: { + kind: "response", + status: 200, + headers: [ + { + name: "content-type", + value: literal("application/json"), + }, + ], + body: literal('{"name":"Ada"}'), + }, + }, + ], + }, + secrets: [ + { + secret_id: "session", + environment_variable: "REA_TEST_SESSION", + purpose: "input", + redaction: "replace-with-secret-reference", + }, + { + secret_id: "login_input", + environment_variable: "REA_TEST_PASSWORD", + purpose: "input", + redaction: "replace-with-secret-reference", + }, + { + secret_id: "cookie", + environment_variable: "REA_TEST_COOKIE", + purpose: "storage", + redaction: "replace-with-secret-reference", + }, + ], + redaction: { + secret_values: "replace-with-secret-reference", + query_parameter_names: ["token"], + }, + capture: { + after_each_step: ["screenshot", "url", "accessibility"], + at_end: ["dom", "storage"], + events: ["console", "page-errors", "network", "websockets"], + }, + limits: { + max_duration_ms: 60_000, + action_timeout_ms: 5_000, + navigation_timeout_ms: 10_000, + max_events: 2_000, + max_frames: 100, + max_workers: 20, + max_popups: 10, + max_websockets: 100, + max_dom_nodes: 10_000, + max_accessibility_nodes: 10_000, + max_screenshots: 16, + max_screenshot_bytes: 4 * 1_024 * 1_024, + max_storage_entries: 256, + max_total_metadata_bytes: 4 * 1_024 * 1_024, + }, + approved: true, +}); + +describe("browserScenarioSchema", () => { + it("accepts and normalizes a bounded declared scenario", () => { + const parsed = browserScenarioSchema.parse(baseScenario()); + expect(parsed.allowed_origins).toEqual([ + "https://api.example.test", + "https://app.example.test", + ]); + expect(parsed.environment.viewport.device_scale_factor).toBe(1); + expect(parsed.redaction.header_names).toEqual([ + "authorization", + "cookie", + "proxy-authorization", + "set-cookie", + ]); + }); + + it("redacts overlapping secrets longest-first", () => { + const scenario = browserScenarioSchema.parse(baseScenario()); + const secrets = BrowserScenarioSecrets.resolve(scenario, { + REA_TEST_SESSION: "prefix", + REA_TEST_PASSWORD: "prefix-suffix", + REA_TEST_COOKIE: "cookie", + }); + expect(secrets?.redact("prefix-suffix")).toBe("[REDACTED:login_input]"); + }); + + it("rejects unsupported actions", () => { + const scenario = baseScenario(); + scenario.actions = [ + { + step_id: "script", + action: "evaluate", + expression: "document.cookie", + } as never, + ]; + expect(browserScenarioSchema.safeParse(scenario).success).toBe(false); + }); + + it("rejects every undeclared navigation, storage, and replay origin", () => { + for (const mutate of [ + (scenario: ReturnType) => { + scenario.start_url.url = "https://other.example.test/"; + }, + (scenario: ReturnType) => { + scenario.actions[2] = { + ...scenario.actions[2], + destination: { url: "https://other.example.test/" }, + } as never; + }, + (scenario: ReturnType) => { + scenario.storage.local_storage[0]!.origin = + "https://other.example.test"; + }, + (scenario: ReturnType) => { + scenario.request_replay.routes[0]!.request.url = + "https://other.example.test/data"; + }, + ]) { + const scenario = baseScenario(); + mutate(scenario); + expect(browserScenarioSchema.safeParse(scenario).success).toBe(false); + } + }); + + it("rejects redirects outside declared origins", () => { + const scenario = baseScenario(); + scenario.request_replay.routes[0]!.response = { + kind: "redirect", + status: 302, + destination: { url: "https://other.example.test/" }, + } as never; + expect(browserScenarioSchema.safeParse(scenario).success).toBe(false); + }); + + it("rejects missing, duplicate, and unused secret declarations", () => { + const missing = baseScenario(); + missing.secrets = missing.secrets.filter( + ({ secret_id }) => secret_id !== "login_input", + ); + expect(browserScenarioSchema.safeParse(missing).success).toBe(false); + + const duplicate = baseScenario(); + duplicate.secrets.push({ ...duplicate.secrets[0]! }); + expect(browserScenarioSchema.safeParse(duplicate).success).toBe(false); + + const unused = baseScenario(); + unused.secrets.push({ + secret_id: "unused", + environment_variable: "REA_TEST_UNUSED", + purpose: "input", + redaction: "replace-with-secret-reference", + }); + expect(browserScenarioSchema.safeParse(unused).success).toBe(false); + }); + + it("rejects secret query values without named redaction", () => { + const scenario = baseScenario(); + scenario.redaction.query_parameter_names = []; + expect(browserScenarioSchema.safeParse(scenario).success).toBe(false); + }); + + it("rejects literal credential headers and provider-owned headers", () => { + for (const name of ["authorization", "content-length"]) { + const scenario = baseScenario(); + scenario.request_replay.routes[0]!.response = { + kind: "response", + status: 200, + headers: [{ name, value: literal("raw") }], + } as never; + expect(browserScenarioSchema.safeParse(scenario).success).toBe(false); + } + }); + + it("rejects duplicate replay response headers case-insensitively", () => { + const scenario = baseScenario(); + const response = scenario.request_replay.routes[0]!.response; + if (response.kind !== "response") + throw new Error("Expected replay response fixture"); + response.headers.push({ + name: "Content-Type", + value: literal("text/plain"), + }); + expect(browserScenarioSchema.safeParse(scenario).success).toBe(false); + }); + + it("rejects raw secret-shaped action values", () => { + const scenario = baseScenario(); + scenario.actions[0] = { + ...scenario.actions[0], + value: "literal-input", + } as never; + expect(browserScenarioSchema.safeParse(scenario).success).toBe(false); + }); + + it("rejects embedded URL credentials and query values", () => { + const credentialedUrl = new URL("https://app.example.test/"); + credentialedUrl.username = String.fromCodePoint(120); + for (const url of [ + credentialedUrl.href, + "https://app.example.test/?token=raw", + ]) { + const scenario = baseScenario(); + scenario.start_url.url = url; + expect(browserScenarioSchema.safeParse(scenario).success).toBe(false); + } + }); + + it("requires explicit external ownership for CDP connections", () => { + const scenario = baseScenario(); + scenario.browser = { + mode: "connect", + cdp_endpoint: "http://127.0.0.1:9222", + target_id: "page-1", + ownership: "external", + cleanup: "disconnect-only", + } as never; + expect(browserScenarioSchema.safeParse(scenario).success).toBe(true); + + scenario.browser.cleanup = "close-browser" as never; + expect(browserScenarioSchema.safeParse(scenario).success).toBe(false); + }); +}); diff --git a/tests/browserScenarioDiff.test.ts b/tests/browserScenarioDiff.test.ts new file mode 100644 index 000000000..7d4ef3d34 --- /dev/null +++ b/tests/browserScenarioDiff.test.ts @@ -0,0 +1,280 @@ +import { createHash } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import { + compareBrowserScenarios, + compareBrowserScenariosInputSchema, +} from "../src/domain/browserScenarioDiff.js"; +import { + browserScenarioCaptureSchema, + type BrowserScenarioCapture, +} from "../src/domain/browserScenarioCapture.js"; + +describe("browser scenario comparison", () => { + it("records canonical literal normalization and ignores declared volatile fields", () => { + const before = scenarioCapture({ + domText: "
Settings
", + elapsedMs: 11, + eventSequence: 3, + }); + const after = scenarioCapture({ + domText: "
Settings
", + elapsedMs: 900, + eventSequence: 88, + }); + const rules = [ + { + rule_id: "new-build", + artifacts: ["dom"] as const, + match: "build-456", + replacement: "[BUILD]", + }, + { + rule_id: "old-build", + artifacts: ["dom"] as const, + match: "build-123", + replacement: "[BUILD]", + }, + ]; + + const compared = compareBrowserScenarios( + compareBrowserScenariosInputSchema.parse({ + before_scenario: before, + after_scenario: after, + normalization: { rules }, + }), + ); + const reordered = compareBrowserScenarios( + compareBrowserScenariosInputSchema.parse({ + before_scenario: before, + after_scenario: after, + normalization: { + rules: [...rules].reverse().map((rule) => ({ + ...rule, + artifacts: [...rule.artifacts].reverse(), + })), + }, + }), + ); + + expect(compared.overall_status).toBe("unchanged"); + expect(compared.alignment.status).toBe("aligned"); + expect(compared.artifact_diffs.total).toBe(0); + expect(compared.normalization).toMatchObject({ + rules: [ + { rule_id: "new-build", artifacts: ["dom"] }, + { rule_id: "old-build", artifacts: ["dom"] }, + ], + }); + expect(reordered.normalization.sha256).toBe(compared.normalization.sha256); + }); + + it("exposes action mismatches and normalized artifact-level changes", () => { + const before = scenarioCapture({ + domText: "
Settings
", + }); + const after = scenarioCapture({ + action: "dblclick", + domText: "
Advanced settings
", + }); + + const compared = compareBrowserScenarios( + compareBrowserScenariosInputSchema.parse({ + before_scenario: before, + after_scenario: after, + }), + ); + const step = compared.steps.find( + ({ step_id: stepId }) => stepId === "open-settings", + ); + + expect(compared.overall_status).toBe("changed"); + expect(compared.alignment).toMatchObject({ + status: "partial", + failures: [ + expect.objectContaining({ + code: "action_mismatch", + step_id: "open-settings", + }), + ], + }); + expect(step).toMatchObject({ + status: "changed", + artifact_diffs: [ + expect.objectContaining({ + artifact: "action_state", + status: "changed", + }), + expect.objectContaining({ artifact: "dom", status: "changed" }), + ], + }); + expect(compared.artifact_diffs).toEqual({ + total: 2, + retained: 2, + omitted: 0, + }); + }); + + it("reports missing aligned steps without claiming equality", () => { + const before = scenarioCapture({}); + const after = scenarioCapture({ stepId: "open-profile" }); + + const compared = compareBrowserScenarios( + compareBrowserScenariosInputSchema.parse({ + before_scenario: before, + after_scenario: after, + }), + ); + + expect(compared.overall_status).toBe("unknown"); + expect(compared.alignment).toMatchObject({ + status: "partial", + aligned_steps: 1, + before_only: ["open-settings"], + after_only: ["open-profile"], + failures: expect.arrayContaining([ + expect.objectContaining({ + code: "missing_before_step", + step_id: "open-profile", + }), + expect.objectContaining({ + code: "missing_after_step", + step_id: "open-settings", + }), + ]), + }); + }); + + it("rejects ambiguous input pairs and duplicate normalization rule IDs", () => { + const capture = scenarioCapture({}); + expect( + compareBrowserScenariosInputSchema.safeParse({ + before_scenario: capture, + after_scenario: capture, + normalization: { + rules: [ + { + rule_id: "volatile", + artifacts: ["dom"], + match: "one", + replacement: "[VALUE]", + }, + { + rule_id: "volatile", + artifacts: ["url"], + match: "two", + replacement: "[VALUE]", + }, + ], + }, + }).success, + ).toBe(false); + }); +}); + +interface ScenarioCaptureOptions { + readonly stepId?: string; + readonly action?: string; + readonly domText?: string; + readonly elapsedMs?: number; + readonly eventSequence?: number; +} + +const scenarioCapture = ( + options: ScenarioCaptureOptions, +): BrowserScenarioCapture => { + const url = { + url: "https://app.example.test/settings", + origin: "https://app.example.test", + query_parameter_names: [], + redacted: false, + }; + const completeness = { + status: "complete", + equality_eligible: true, + missing_sections: [], + truncated_sections: [], + }; + const artifacts = (text: string) => ({ + screenshot: { state: "not_requested" }, + dom: { state: "captured", value: textArtifact(text) }, + accessibility: { state: "not_requested" }, + url: { state: "captured", value: url }, + history: { state: "not_requested" }, + storage: { state: "not_requested" }, + }); + const eventSequence = options.eventSequence ?? 1; + return browserScenarioCaptureSchema.parse({ + schema_version: 1, + browser: { + mode: "launch", + process_ownership: "provider-owned", + cleanup: "terminated-owned-process", + product: "Chromium", + version: "149.0", + }, + scenario: { + start_origin: "https://app.example.test", + allowed_origins: ["https://app.example.test"], + action_count: 1, + secret_references: [], + }, + duration_ms: options.elapsedMs ?? 10, + steps: [ + { + step_index: 0, + step_id: "scenario_start", + action: "scenario_start", + status: "completed", + elapsed_ms: 0, + before_url: url, + after_url: url, + error: null, + event_sequence_start: 1, + event_sequence_end: 0, + artifacts: artifacts("
Ready
"), + completeness, + }, + { + step_index: 1, + step_id: options.stepId ?? "open-settings", + action: options.action ?? "click", + status: "completed", + elapsed_ms: options.elapsedMs ?? 10, + before_url: url, + after_url: url, + error: null, + event_sequence_start: eventSequence, + event_sequence_end: eventSequence, + artifacts: artifacts(options.domText ?? "
Settings
"), + completeness, + }, + ], + events: { + retained: 1, + dropped: 0, + items: [ + { + sequence: eventSequence, + step_index: 1, + kind: "console", + level: "info", + text: "settings-ready", + url: null, + }, + ], + }, + completeness, + limitations: [], + }); +}; + +const textArtifact = (text: string) => { + const bytes = Buffer.from(text); + return { + sha256: createHash("sha256").update(bytes).digest("hex"), + bytes: bytes.byteLength, + text, + }; +}; diff --git a/tests/browserScenarioMcp.test.ts b/tests/browserScenarioMcp.test.ts new file mode 100644 index 000000000..a3ed95497 --- /dev/null +++ b/tests/browserScenarioMcp.test.ts @@ -0,0 +1,278 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { dirname } from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; + +import type { BrowserScenarioCapturePort } from "../src/application/BrowserScenarioCapturePort.js"; +import { BinarySession } from "../src/application/BinarySession.js"; +import { loadConfiguredPermissionAuthority } from "../src/application/PermissionConfiguration.js"; +import type { + ExecutionOptions, + ProviderIdentity, +} from "../src/application/AnalysisProvider.js"; +import { CdpBrowserProvider } from "../src/browser/CdpBrowserProvider.js"; +import { parseConfig } from "../src/config.js"; +import { sanitizeBrowserUrl } from "../src/domain/browserObservation.js"; +import type { BrowserScenario } from "../src/domain/browserScenario.js"; +import { + browserScenarioCaptureSchema, + type BrowserScenarioCapture, +} from "../src/domain/browserScenarioCapture.js"; +import type { AnalysisError } from "../src/domain/errors.js"; +import { ok, type Result } from "../src/domain/result.js"; +import { createServer } from "../src/server/createServer.js"; +import { observed } from "./fixtures/analysisExecution.js"; + +const providerIdentity: ProviderIdentity = { + id: "test-browser-scenario", + name: "Test browser scenario provider", + version: "1", +}; + +class FakeBrowserScenarioProvider implements BrowserScenarioCapturePort { + readonly scenarios: BrowserScenario[] = []; + + identity(): ProviderIdentity { + return providerIdentity; + } + + captureScenario( + scenario: BrowserScenario, + _options?: ExecutionOptions, + ): Promise> { + this.scenarios.push(scenario); + return Promise.resolve(ok(captureFor(scenario))); + } +} + +const artifacts = { + screenshot: { state: "not_requested" }, + dom: { state: "not_requested" }, + accessibility: { state: "not_requested" }, + url: { state: "not_requested" }, + history: { state: "not_requested" }, + storage: { state: "not_requested" }, +} as const; + +const captureFor = (scenario: BrowserScenario): BrowserScenarioCapture => { + const start = scenario.start_url.url; + const step = (stepIndex: number, stepId: string, action: string) => ({ + step_index: stepIndex, + step_id: stepId, + action, + status: "completed", + elapsed_ms: 1, + before_url: sanitizeBrowserUrl(start), + after_url: sanitizeBrowserUrl(start), + error: null, + event_sequence_start: 1, + event_sequence_end: 0, + artifacts, + completeness: { + status: "complete", + equality_eligible: true, + missing_sections: [], + truncated_sections: [], + }, + }); + return browserScenarioCaptureSchema.parse({ + schema_version: 1, + browser: { + mode: scenario.browser.mode, + process_ownership: "provider-owned", + cleanup: "terminated-owned-process", + product: "Test Browser", + version: "1", + }, + scenario: { + start_origin: new URL(start).origin, + allowed_origins: scenario.allowed_origins, + action_count: scenario.actions.length, + secret_references: scenario.secrets.map(({ secret_id: id }) => id), + }, + duration_ms: 2, + steps: [ + step(0, "scenario_start", "goto_start"), + ...scenario.actions.map((action, index) => + step(index + 1, action.step_id, action.action), + ), + ], + events: { retained: 0, dropped: 0, items: [] }, + completeness: { + status: "complete", + equality_eligible: true, + missing_sections: [], + truncated_sections: [], + }, + limitations: [], + }); +}; + +const scenario = (origin = "https://app.example.test") => ({ + schema_version: 1, + browser: { + mode: "launch", + executable_path: process.execPath, + headless: true, + user_data: "temporary-owned", + cleanup: "close-and-delete-profile", + }, + start_url: { url: `${origin}/`, query: [] }, + allowed_origins: [origin], + environment: { + viewport: { width: 1_280, height: 720 }, + locale: "en-US", + timezone: "UTC", + color_scheme: "light", + reduced_motion: "reduce", + service_workers: "block", + }, + actions: [ + { + step_id: "login-input", + action: "fill", + locator: { kind: "test_id", value: "password" }, + value: { source: "secret", secret_id: "login_input" }, + }, + ], + storage: {}, + request_replay: { mode: "disabled" }, + secrets: [ + { + secret_id: "login_input", + environment_variable: "REA_TEST_PASSWORD", + purpose: "input", + redaction: "replace-with-secret-reference", + }, + ], + redaction: { + secret_values: "replace-with-secret-reference", + query_parameter_names: [], + }, + capture: { after_each_step: [], at_end: [], events: [] }, + limits: { + max_duration_ms: 10_000, + action_timeout_ms: 1_000, + navigation_timeout_ms: 1_000, + max_events: 100, + max_frames: 10, + max_workers: 5, + max_popups: 2, + max_websockets: 10, + max_dom_nodes: 1_000, + max_accessibility_nodes: 1_000, + max_screenshots: 2, + max_screenshot_bytes: 1_048_576, + max_storage_entries: 100, + max_total_metadata_bytes: 1_048_576, + }, + approved: true, +}); + +describe("browser scenario MCP tool", () => { + const resources: Array<{ close(): Promise }> = []; + + afterEach(async () => { + await Promise.all(resources.splice(0).map((item) => item.close())); + }); + + test("authorizes exact scope, records Evidence, and retains no secret value", async () => { + const configured = parseConfig({ + REA_BROWSER_SCENARIO_ENABLED: "true", + REA_BROWSER_SCENARIO_AUTO_GRANT: "true", + REA_BROWSER_SCENARIO_EXECUTABLE_ROOTS_JSON: JSON.stringify([ + dirname(process.execPath), + ]), + REA_BROWSER_SCENARIO_ALLOWED_ORIGINS_JSON: '["https://app.example.test"]', + REA_BROWSER_SCENARIO_ALLOWED_ENV_JSON: '["REA_TEST_PASSWORD"]', + }); + if (!configured.ok) throw configured.error; + const authority = await loadConfiguredPermissionAuthority(configured.value); + if (!authority.ok) throw authority.error; + const provider = new FakeBrowserScenarioProvider(); + const session = new BinarySession(() => ({ + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(), + })); + const server = createServer(session, session, { + browserObservation: new CdpBrowserProvider(), + browserScenarioCapture: provider, + permissionAuthority: authority.value, + availabilityPolicy: () => ({ + processCaptureEnabled: false, + evidenceFileRoots: 0, + investigationInputRoots: 0, + browserObservationEnabled: true, + browserScenarioEnabled: true, + }), + }); + const client = new Client({ name: "browser-scenario-test", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(client, server, session); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const captured = await client.callTool({ + name: "capture_browser_scenario", + arguments: scenario(), + }); + expect(captured.isError, JSON.stringify(captured)).not.toBe(true); + expect(captured.structuredContent).toMatchObject({ + result: { + scenario: { + secret_references: ["login_input"], + }, + }, + }); + expect(provider.scenarios).toHaveLength(1); + expect(JSON.stringify(captured.structuredContent)).not.toContain( + "correct horse battery staple", + ); + const evidenceId = Reflect.get( + captured.structuredContent ?? {}, + "evidence_id", + ); + expect(session.evidenceById(String(evidenceId))).toMatchObject({ + predicate_type: "rea.browser-scenario-capture/v1", + }); + const captureResult = Reflect.get( + captured.structuredContent ?? {}, + "result", + ); + const compared = await client.callTool({ + name: "compare_web_captures", + arguments: { + before_scenario: captureResult, + after_scenario: captureResult, + normalization: { rules: [] }, + }, + }); + expect(compared.isError, JSON.stringify(compared)).not.toBe(true); + expect(compared.structuredContent).toMatchObject({ + result: { + comparison_kind: "browser_scenario", + overall_status: "unchanged", + alignment: { status: "aligned", aligned_steps: 2 }, + }, + }); + + const denied = await client.callTool({ + name: "capture_browser_scenario", + arguments: scenario("https://other.example.test"), + }); + expect(denied.isError).toBe(true); + expect(provider.scenarios).toHaveLength(1); + + const unapprovedEnvironment = scenario(); + const secretDeclaration = unapprovedEnvironment.secrets.at(0); + if (secretDeclaration === undefined) + throw new Error("Scenario secret fixture disappeared"); + secretDeclaration.environment_variable = "REA_UNAPPROVED_SECRET"; + const deniedSecret = await client.callTool({ + name: "capture_browser_scenario", + arguments: unapprovedEnvironment, + }); + expect(deniedSecret.isError).toBe(true); + expect(provider.scenarios).toHaveLength(1); + }); +}); diff --git a/tests/capabilityInventory.test.ts b/tests/capabilityInventory.test.ts index 65dcaf425..797d71903 100644 --- a/tests/capabilityInventory.test.ts +++ b/tests/capabilityInventory.test.ts @@ -7,7 +7,9 @@ const enabledPolicy: Parameters[1] = { evidenceFileRoots: 1, investigationInputRoots: 1, browserObservationEnabled: true, + browserScenarioEnabled: true, electronObservationEnabled: true, + v8InspectorObservationEnabled: true, javascriptReplayEnabled: true, managedRuntimeEnabled: true, }; @@ -129,7 +131,9 @@ describe("capability inventory", () => { it.each([ ["capture_process_scenario", { processCaptureEnabled: false }], ["inspect_web_page", { browserObservationEnabled: false }], + ["capture_browser_scenario", { browserScenarioEnabled: false }], ["inspect_electron_page", { electronObservationEnabled: false }], + ["observe_javascript_runtime", { v8InspectorObservationEnabled: false }], ["run_controlled_replay", { javascriptReplayEnabled: false }], ["plan_managed_runtime_correlation", { managedRuntimeEnabled: false }], ] as const)("reports stable policy denial for %s", (name, override) => { @@ -153,6 +157,7 @@ describe("capability inventory", () => { investigationInputRoots: 0, browserObservationEnabled: false, electronObservationEnabled: false, + v8InspectorObservationEnabled: false, javascriptReplayEnabled: false, managedRuntimeEnabled: false, }); @@ -163,4 +168,30 @@ describe("capability inventory", () => { expect(availability.remediation?.length).toBeGreaterThan(0); } }); + + it("projects negotiated client features into per-tool requirements", () => { + const withoutElicitation = buildCapabilityInventory( + status(), + enabledPolicy, + ).find(({ name }) => name === "capture_process_scenario"); + const withElicitation = buildCapabilityInventory(status(), enabledPolicy, { + elicitation_form: true, + elicitation_url: false, + roots: false, + sampling: false, + }).find(({ name }) => name === "capture_process_scenario"); + + expect(withoutElicitation?.client_requirements).toEqual({ + required: [], + optional: ["elicitation_form"], + missing_required: [], + missing_optional: ["elicitation_form"], + }); + expect(withElicitation?.client_requirements).toEqual({ + required: [], + optional: ["elicitation_form"], + missing_required: [], + missing_optional: [], + }); + }); }); diff --git a/tests/cdpBrowserProvider.test.ts b/tests/cdpBrowserProvider.test.ts index 60f8031ea..f4c548485 100644 --- a/tests/cdpBrowserProvider.test.ts +++ b/tests/cdpBrowserProvider.test.ts @@ -257,6 +257,7 @@ describe("CdpBrowserProvider", () => { approved: true, observation_ms: 0, include_storage_keys: true, + include_storage_fingerprints: true, }), ); @@ -364,6 +365,22 @@ describe("CdpBrowserProvider", () => { session_storage_keys: ["public-key"], indexed_db_names: ["app-db"], cache_names: ["assets-v1"], + content_fingerprints: expect.arrayContaining([ + expect.objectContaining({ scope: "cookie", complete: true }), + expect.objectContaining({ scope: "local_storage", complete: true }), + expect.objectContaining({ scope: "session_storage", complete: true }), + expect.objectContaining({ + scope: "indexed_db_schema", + complete: true, + }), + expect.objectContaining({ + scope: "indexed_db_record", + complete: true, + }), + expect.objectContaining({ scope: "cache_entry", complete: true }), + ]), + fingerprint_algorithm: "sha256", + fingerprints_complete: true, values_redacted: true, }), ); @@ -388,6 +405,9 @@ describe("CdpBrowserProvider", () => { "unknown-origin-console-secret", "unknown-console-value-secret", "storage-secret", + "cookie-secret", + "indexed-db-secret", + "cache-body-secret", "secret-id", "dom-url-secret", "link-secret", @@ -1243,6 +1263,7 @@ describe("CdpBrowserProvider", () => { expect(result.value.storage.session_storage_keys).toHaveLength(1); expect(result.value.storage.indexed_db_names).toHaveLength(1); expect(result.value.storage.cache_names).toHaveLength(1); + expect(result.value.storage.content_fingerprints).toEqual([]); }); it("degrades optional domains but propagates protocol and payload failures", async () => { diff --git a/tests/cdpCaptureDocuments.test.ts b/tests/cdpCaptureDocuments.test.ts index d1d8a8a79..f83a02de9 100644 --- a/tests/cdpCaptureDocuments.test.ts +++ b/tests/cdpCaptureDocuments.test.ts @@ -83,6 +83,11 @@ describe("CDP document capture", () => { role: { value: "button" }, name: { value: "😀a" }, description: { value: "éé" }, + childIds: ["missing-child"], + properties: [ + { name: "disabled", value: { value: true } }, + { name: "valuetext", value: { value: "private value" } }, + ], }, ], }, @@ -95,7 +100,12 @@ describe("CDP document capture", () => { }, ); - expect(capture.nodes[0]).toMatchObject({ name: "😀", description: "é" }); + expect(capture.nodes[0]).toMatchObject({ + name: "😀", + description: "é", + states: [{ name: "disabled", value: true }], + }); + expect(capture.treeIncomplete).toBe(true); expect(capture.textCapture).toEqual({ status: "truncated", retained_bytes: 6, diff --git a/tests/config.test.ts b/tests/config.test.ts index a34c9d4e7..89a2fd955 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -32,9 +32,20 @@ describe("runtime configuration", () => { expect(result.value.browserObservationEnabled).toBe(false); expect(result.value.browserCdpEndpoints).toEqual([]); expect(result.value.browserAllowedOrigins).toEqual([]); + expect(result.value.browserScenarioPolicy).toEqual({ + enabled: false, + executableRoots: [], + cdpEndpoints: [], + allowedOrigins: [], + allowedEnvironment: [], + }); expect(result.value.electronObservationEnabled).toBe(false); expect(result.value.electronCdpEndpoints).toEqual([]); expect(result.value.electronFileRoots).toEqual([]); + expect(result.value.v8InspectorObservationEnabled).toBe(false); + expect(result.value.v8InspectorEndpoints).toEqual([]); + expect(result.value.v8InspectorFileRoots).toEqual([]); + expect(result.value.v8InspectorAllowedOrigins).toEqual([]); expect(result.value.javascriptReplayPolicy).toMatchObject({ enabled: false, roots: [], @@ -112,6 +123,26 @@ describe("runtime configuration", () => { }); }); + it("builds a separate V8 Inspector endpoint, root, and origin ceiling", () => { + const result = parseConfig({ + REA_V8_INSPECTOR_OBSERVE_ENABLED: "true", + REA_V8_INSPECTOR_ENDPOINTS_JSON: '["http://127.0.0.1:9229"]', + REA_V8_INSPECTOR_FILE_ROOTS_JSON: '["/tmp/node-app"]', + REA_V8_INSPECTOR_ALLOWED_ORIGINS_JSON: '["http://127.0.0.1:3000"]', + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.permissionCeilings).toContainEqual({ + capability: "v8_inspector_observe", + roots: ["/tmp/node-app"], + executables: [], + environment_names: [], + origins: ["http://127.0.0.1:9229", "http://127.0.0.1:3000"], + network: "loopback", + mount: false, + }); + }); + it("can configure a process-capture ceiling without an implicit grant", () => { const result = parseConfig({ REA_PROCESS_CAPTURE_ENABLED: "true", @@ -414,6 +445,48 @@ describe("runtime configuration", () => { ); }); + it("requires an explicit grant for exact-scope browser automation", () => { + const environment = { + REA_BROWSER_SCENARIO_ENABLED: "true", + REA_BROWSER_SCENARIO_EXECUTABLE_ROOTS_JSON: '["/opt/chromium"]', + REA_BROWSER_SCENARIO_CDP_ENDPOINTS_JSON: '["http://127.0.0.1:9222"]', + REA_BROWSER_SCENARIO_ALLOWED_ORIGINS_JSON: '["https://app.example.test"]', + REA_BROWSER_SCENARIO_ALLOWED_ENV_JSON: '["REA_TEST_PASSWORD"]', + }; + const result = parseConfig(environment); + if (!result.ok) throw result.error; + expect(result.value.browserScenarioPolicy).toEqual({ + enabled: true, + executableRoots: ["/opt/chromium"], + cdpEndpoints: ["http://127.0.0.1:9222"], + allowedOrigins: ["https://app.example.test"], + allowedEnvironment: ["REA_TEST_PASSWORD"], + }); + expect(result.value.permissionCeilings).toContainEqual({ + capability: "browser_automate", + roots: [], + executables: ["/opt/chromium"], + environment_names: ["REA_TEST_PASSWORD"], + origins: ["http://127.0.0.1:9222", "https://app.example.test"], + network: "external", + mount: false, + }); + expect(result.value.administratorPermissionGrants).not.toContainEqual( + expect.objectContaining({ capability: "browser_automate" }), + ); + const automatic = parseConfig({ + ...environment, + REA_BROWSER_SCENARIO_AUTO_GRANT: "true", + }); + if (!automatic.ok) throw automatic.error; + expect(automatic.value.administratorPermissionGrants).toContainEqual( + expect.objectContaining({ + capability: "browser_automate", + lifetime: "administrator", + }), + ); + }); + it.each([ '["http://localhost:9222"]', '["http://192.168.1.5:9222"]', diff --git a/tests/conformance/README.md b/tests/conformance/README.md index 8805402bf..60baa45b2 100644 --- a/tests/conformance/README.md +++ b/tests/conformance/README.md @@ -27,3 +27,9 @@ large fixture contains 1,205 uniquely named functions and strings so provider pagination can be checked at boundaries of 500, 1,000, and 1,205. Objective-C, Swift, and N-API-like fixtures provide metadata-oriented inputs without package or network dependencies. + +`readiness/` adds source-only generated JavaScript CLI and Electron fixtures for +the public reconstruction-readiness journey. `npm run verify:readiness` combines +them with the compiled native fixtures, exercises CLI and MCP parity, and emits +the versioned machine-readable readiness report documented in +`docs/reconstruction-readiness.md`. diff --git a/tests/conformance/readiness/electron/main.js b/tests/conformance/readiness/electron/main.js new file mode 100644 index 000000000..947847115 --- /dev/null +++ b/tests/conformance/readiness/electron/main.js @@ -0,0 +1,23 @@ +const { + app, + BrowserWindow, + ipcMain, + shell, + utilityProcess, +} = require("electron"); + +const windows = new Set(); +app.on("open-url", (_event, url) => { + for (const window of windows) window.webContents.send("deep-link", url); +}); +app.whenReady().then(() => { + const window = new BrowserWindow({ + webPreferences: { preload: require.resolve("./preload.js") }, + }); + windows.add(window); + window.loadFile("renderer.html"); + utilityProcess.fork(require.resolve("./utility.js")); + ipcMain.handle("readiness:echo", (_event, value) => ({ value })); + window.webContents.on("render-process-gone", () => window.reload()); + shell.openExternal("https://blocked.invalid").catch(() => undefined); +}); diff --git a/tests/conformance/readiness/electron/package.json b/tests/conformance/readiness/electron/package.json new file mode 100644 index 000000000..d154971db --- /dev/null +++ b/tests/conformance/readiness/electron/package.json @@ -0,0 +1,5 @@ +{ + "name": "rea-readiness-electron", + "version": "1.0.0", + "main": "main.js" +} diff --git a/tests/conformance/readiness/electron/preload.js b/tests/conformance/readiness/electron/preload.js new file mode 100644 index 000000000..460e37b27 --- /dev/null +++ b/tests/conformance/readiness/electron/preload.js @@ -0,0 +1,6 @@ +const { contextBridge, ipcRenderer } = require("electron"); + +contextBridge.exposeInMainWorld("readiness", { + echo: (value) => ipcRenderer.invoke("readiness:echo", value), + onDeepLink: (listener) => ipcRenderer.on("deep-link", listener), +}); diff --git a/tests/conformance/readiness/electron/renderer.html b/tests/conformance/readiness/electron/renderer.html new file mode 100644 index 000000000..b78405bba --- /dev/null +++ b/tests/conformance/readiness/electron/renderer.html @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/tests/conformance/readiness/electron/renderer.js b/tests/conformance/readiness/electron/renderer.js new file mode 100644 index 000000000..9c18ac565 --- /dev/null +++ b/tests/conformance/readiness/electron/renderer.js @@ -0,0 +1,7 @@ +document.querySelector("#run").addEventListener("click", async () => { + document.querySelector("#result").textContent = + await window.readiness.echo("renderer"); +}); +window.readiness.onDeepLink((_event, url) => { + document.querySelector("#result").textContent = url; +}); diff --git a/tests/conformance/readiness/electron/utility.js b/tests/conformance/readiness/electron/utility.js new file mode 100644 index 000000000..3424604f1 --- /dev/null +++ b/tests/conformance/readiness/electron/utility.js @@ -0,0 +1,5 @@ +const { parentPort } = require("electron"); + +parentPort.on("message", (event) => { + parentPort.postMessage({ echoed: event.data }); +}); diff --git a/tests/conformance/readiness/javascript-cli/index.js b/tests/conformance/readiness/javascript-cli/index.js new file mode 100644 index 000000000..b0b692f2e --- /dev/null +++ b/tests/conformance/readiness/javascript-cli/index.js @@ -0,0 +1,34 @@ +import { spawn } from "node:child_process"; +import { readFile, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import process from "node:process"; +import readline from "node:readline"; + +const configurationPath = + process.env.REA_READINESS_CONFIG ?? "./readiness-config.json"; +const configuration = JSON.parse( + await readFile(configurationPath, "utf8").catch(() => "{}"), +); +const prompt = readline.createInterface({ + input: process.stdin, + output: process.stdout, +}); +process.stdout.write("\u001b[?1049h"); +const answer = await new Promise((resolve) => + prompt.question("readiness> ", resolve), +); +prompt.close(); +const child = spawn(process.execPath, ["-e", "process.stdout.write('child')"]); +const server = createServer((request, response) => { + response.end(JSON.stringify({ path: request.url, answer })); +}); +server.listen(Number(configuration.port ?? 0), "127.0.0.1"); +process.on("SIGTERM", () => { + child.kill("SIGTERM"); + server.close(); + process.stdout.write("\u001b[?1049l"); +}); +await writeFile( + configurationPath, + JSON.stringify({ ...configuration, lastAnswer: answer }), +); diff --git a/tests/conformance/readiness/javascript-cli/package.json b/tests/conformance/readiness/javascript-cli/package.json new file mode 100644 index 000000000..64b35d1ad --- /dev/null +++ b/tests/conformance/readiness/javascript-cli/package.json @@ -0,0 +1,6 @@ +{ + "name": "rea-readiness-javascript-cli", + "version": "1.0.0", + "type": "module", + "bin": "index.js" +} diff --git a/tests/conformance/runtime-inspector/node-target.mjs b/tests/conformance/runtime-inspector/node-target.mjs new file mode 100644 index 000000000..147e1cdd2 --- /dev/null +++ b/tests/conformance/runtime-inspector/node-target.mjs @@ -0,0 +1 @@ +setInterval(() => undefined, 1_000); diff --git a/tests/contractSnapshot.test.ts b/tests/contractSnapshot.test.ts index 850c4deb9..833c0943b 100644 --- a/tests/contractSnapshot.test.ts +++ b/tests/contractSnapshot.test.ts @@ -12,7 +12,9 @@ import { ARTIFACT_TOOL_CONTRACTS } from "../src/contracts/artifactToolContracts. import { MANAGED_TOOL_CONTRACTS } from "../src/contracts/managedToolContracts.js"; import { MANAGED_WORKFLOW_TOOL_CONTRACTS } from "../src/contracts/managedWorkflowToolContracts.js"; import { BROWSER_TOOL_CONTRACTS } from "../src/contracts/browserToolContracts.js"; +import { BROWSER_SCENARIO_TOOL_CONTRACTS } from "../src/contracts/browserScenarioToolContracts.js"; import { ELECTRON_TOOL_CONTRACTS } from "../src/contracts/electronToolContracts.js"; +import { JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS } from "../src/contracts/javascriptRuntimeObservationToolContracts.js"; import { APPLICATION_TOOL_CONTRACTS } from "../src/contracts/applicationToolContracts.js"; import { enhancedOutputSchemas, @@ -119,7 +121,9 @@ describe("tool contract surface", () => { ...MANAGED_TOOL_CONTRACTS, ...MANAGED_WORKFLOW_TOOL_CONTRACTS, ...BROWSER_TOOL_CONTRACTS, + ...BROWSER_SCENARIO_TOOL_CONTRACTS, ...ELECTRON_TOOL_CONTRACTS, + ...JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS, ...APPLICATION_TOOL_CONTRACTS, ...SESSION_TOOL_CONTRACTS, ].map(({ name }) => name); @@ -214,7 +218,9 @@ describe("tool contract surface", () => { ...MANAGED_TOOL_CONTRACTS, ...MANAGED_WORKFLOW_TOOL_CONTRACTS, ...BROWSER_TOOL_CONTRACTS, + ...BROWSER_SCENARIO_TOOL_CONTRACTS, ...ELECTRON_TOOL_CONTRACTS, + ...JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS, ...APPLICATION_TOOL_CONTRACTS, ...SESSION_TOOL_CONTRACTS, ]; diff --git a/tests/electronMcp.test.ts b/tests/electronMcp.test.ts index 2b16a29d7..10ce374cc 100644 --- a/tests/electronMcp.test.ts +++ b/tests/electronMcp.test.ts @@ -336,28 +336,11 @@ describe("Electron MCP tools", () => { }, }); - const denied = await client.callTool({ - name: "analyze_javascript_application", - arguments: { input_path: root, approved: true }, - }); - expect(denied).toMatchObject({ - isError: true, - structuredContent: { - error: { - code: "permission_required", - remediation: { - action: expect.stringContaining("administrator configuration"), - elicitation_supported: false, - restart_required: true, - }, - details: { - capability: "investigation_input", - missing: { roots: [root] }, - ceiling: expect.objectContaining({ roots: [] }), - }, - }, - }, - }); + expect( + (await client.listTools()).tools.some( + ({ name }) => name === "analyze_javascript_application", + ), + ).toBe(false); }); const evidenceFor = (session: BinarySession, value: unknown) => { diff --git a/tests/enhancedTools.test.ts b/tests/enhancedTools.test.ts index a36346775..f1118db69 100644 --- a/tests/enhancedTools.test.ts +++ b/tests/enhancedTools.test.ts @@ -213,7 +213,7 @@ describe("enhanced MCP tools", () => { ).toEqual(ENHANCED_TOOL_CONTRACTS.map(({ name }) => name).sort()); }); - it("executes all ten tools through production registration", async () => { + it("executes all twelve tools through production registration", async () => { const client = await connect(); const calls = [ ["swift_classes", { pattern: "Fixture" }], @@ -226,6 +226,8 @@ describe("enhanced MCP tools", () => { ["binary_overview", {}], ["analyze_function", { procedure: "0x1" }], ["trace_feature", { query: "hello", max_operations: 10 }], + ["find_code_for_string", { query: "hello", max_operations: 10 }], + ["trace_call_path", { start: "0x1", goal: "0x2", max_operations: 10 }], ] as const; const results = await Promise.all( calls.map(async ([name, arguments_]) => @@ -277,6 +279,56 @@ describe("enhanced MCP tools", () => { { target_address: "0x30", source_address: "0x21" }, ], }); + expect(results[10]).toMatchObject({ + query: "hello", + matches: [{ type: "string", address: "0x30", value: "hello" }], + truncated: false, + }); + expect(results[11]).toMatchObject({ + start: "0x1", + goal: "0x2", + direction: "forward", + goal_status: "reached", + traversal_path: ["0x1", "0x2"], + nodes: [ + { address: "0x1", depth: 0 }, + { address: "0x2", depth: 1 }, + ], + truncated: false, + }); + }); + + it("returns Evidence IDs and explicit call-path frontiers at limits", async () => { + const client = await connect(); + const result = await client.callTool({ + name: "trace_call_path", + arguments: { + start: "0x1", + goal: "0x3", + max_nodes: 1, + max_operations: 10, + }, + }); + + expect(result.isError).not.toBe(true); + expect(result.structuredContent).toMatchObject({ + evidence_id: expect.stringMatching(/^ev_[a-f0-9]{64}$/u), + result: { + goal_status: "not_reached", + nodes: [{ address: "0x1", depth: 0 }], + frontier: ["0x2"], + truncated: true, + residual_unknowns: expect.arrayContaining([ + expect.stringContaining("reached the node limit"), + expect.stringContaining("reached the edge limit"), + ]), + limits: { + max_nodes: 1, + nodes_visited: 1, + operations_used: 1, + }, + }, + }); }); it("bounds batch concurrency at the parsed maximum of 20", async () => { diff --git a/tests/fixtures/bridgeRegexProbe.py b/tests/fixtures/bridgeRegexProbe.py index d4655670b..b566e7f56 100644 --- a/tests/fixtures/bridgeRegexProbe.py +++ b/tests/fixtures/bridgeRegexProbe.py @@ -7,11 +7,8 @@ import sys def _load_bridge(path): source = Path(path).read_text(encoding="utf-8") - prefix, marker, suffix = source.rpartition("\n_run()") - if not marker or suffix.strip(): - raise RuntimeError("bridge entrypoint shape changed") - namespace = {"__file__": path} - exec(compile(prefix, path, "exec"), namespace) + namespace = {"__file__": path, "__name__": "rea_hopper_bridge"} + exec(compile(source, path, "exec"), namespace) return namespace diff --git a/tests/fixtures/fakeCdpBrowser.ts b/tests/fixtures/fakeCdpBrowser.ts index bb630bd82..66738fefb 100644 --- a/tests/fixtures/fakeCdpBrowser.ts +++ b/tests/fixtures/fakeCdpBrowser.ts @@ -399,10 +399,20 @@ const resultFor = ( return storageUsageResult(); case "DOMStorage.getDOMStorageItems": return domStorageResult(options); + case "Network.getCookies": + return cookieStorageResult(); case "IndexedDB.requestDatabaseNames": return indexedDbResult(options); + case "IndexedDB.requestDatabase": + return indexedDbDatabaseResult(command); + case "IndexedDB.requestData": + return indexedDbDataResult(); case "CacheStorage.requestCacheNames": return cacheStorageResult(options); + case "CacheStorage.requestEntries": + return cacheEntriesResult(port); + case "CacheStorage.requestCachedResponse": + return cachedResponseResult(); default: return {}; } @@ -476,11 +486,53 @@ const domStorageResult = (options: FakeOptions) => ({ : [["public-key", "storage-secret"]], }); +const cookieStorageResult = () => ({ + cookies: [ + { + name: "session", + value: "cookie-secret", + domain: "127.0.0.1", + path: "/", + secure: false, + httpOnly: true, + }, + ], +}); + const indexedDbResult = (options: FakeOptions) => ({ databaseNames: options.extraCollections === true ? ["app-db", "second-db"] : ["app-db"], }); +const indexedDbDatabaseResult = (command: FakeCdpCommand) => ({ + databaseWithObjectStores: { + name: + typeof command.params.databaseName === "string" + ? command.params.databaseName + : "app-db", + version: 1, + objectStores: [ + { + name: "records", + keyPath: { type: "string", string: "id" }, + autoIncrement: false, + indexes: [], + }, + ], + }, +}); + +const indexedDbDataResult = () => ({ + objectStoreDataEntries: [ + { + key: { type: "string", value: "row-1" }, + primaryKey: { type: "string", value: "row-1" }, + value: { type: "string", value: "indexed-db-secret" }, + }, + ], + hasMore: false, +}); + const cacheStorageResult = (options: FakeOptions) => ({ caches: [ { cacheName: "assets-v1", cacheId: "secret-id" }, @@ -490,6 +542,26 @@ const cacheStorageResult = (options: FakeOptions) => ({ ], }); +const cacheEntriesResult = (port: number) => ({ + cacheDataEntries: [ + { + requestURL: `http://127.0.0.1:${String(port)}/cached`, + requestMethod: "GET", + requestHeaders: [], + responseTime: 1, + responseStatus: 200, + responseStatusText: "OK", + responseType: "basic", + responseHeaders: [], + }, + ], + returnCount: 1, +}); + +const cachedResponseResult = () => ({ + response: { body: Buffer.from("cache-body-secret").toString("base64") }, +}); + const versionTargetId = (options: FakeOptions): string => options.electronFileUrl === undefined ? "allowed-page" : "electron-page"; @@ -670,6 +742,17 @@ const accessibilityTree = ( role: { type: "role", value: "button" }, name: { type: "computedString", value: "Submit report" }, description: { type: "computedString", value: "Sends the form" }, + childIds: extraCollections ? ["ax-2"] : [], + properties: [ + { + name: "disabled", + value: { type: "boolean", value: false }, + }, + { + name: "expanded", + value: { type: "booleanOrUndefined", value: false }, + }, + ], }, ...(extraCollections ? [ diff --git a/tests/fixtures/fakeHopper.mjs b/tests/fixtures/fakeHopper.mjs index bf091a59c..7259fc822 100644 --- a/tests/fixtures/fakeHopper.mjs +++ b/tests/fixtures/fakeHopper.mjs @@ -8,6 +8,14 @@ const shutdownResult = () => ({ document_closed: shutdownMode !== "unconfirmed", ...(shutdownMode === "cleanup-required" ? { cleanup_required: true } : {}), }); +const capabilityUnavailableResult = (id) => ({ + id, + error: { + code: -32000, + message: "fixture API is unavailable", + type: "capability_unavailable", + }, +}); const enhancedFixtureResult = (method) => { switch (method) { case "list_segments": @@ -153,6 +161,8 @@ const server = createServer((socket) => { type: "bridge_exception", }, }); + } else if (request.method === "capability_unavailable") { + send(capabilityUnavailableResult(request.id)); } else if (request.method === "current_document") { send({ id: request.id, result: "fixture" }); } else if (enhancedFixtureResult(request.method) !== undefined) { diff --git a/tests/fixtures/fakeV8Inspector.ts b/tests/fixtures/fakeV8Inspector.ts new file mode 100644 index 000000000..4e0790084 --- /dev/null +++ b/tests/fixtures/fakeV8Inspector.ts @@ -0,0 +1,181 @@ +import { createServer } from "node:http"; + +import { WebSocket, WebSocketServer } from "ws"; + +interface InspectorCommand { + readonly id: number; + readonly method: string; + readonly params: Readonly>; +} + +interface FakeV8InspectorOptions { + readonly targetUrl: string; + readonly targetType?: "node" | "page"; + readonly scriptUrls?: readonly string[]; + readonly additionalTargetUrl?: string; + readonly closeOnMethod?: string; +} + +export interface FakeV8Inspector { + readonly endpoint: string; + readonly targetId: string; + readonly commands: readonly InspectorCommand[]; + close(): Promise; +} + +/** Real loopback HTTP/WebSocket fake matching Node Inspector discovery. */ +export const startFakeV8Inspector = async ( + options: FakeV8InspectorOptions, +): Promise => { + const targetId = "00000000-0000-4000-8000-000000000001"; + const otherTargetId = "00000000-0000-4000-8000-000000000002"; + const commands: InspectorCommand[] = []; + const sockets = new Set(); + let port = 0; + const http = createServer((request, response) => { + response.setHeader("content-type", "application/json"); + if (request.url === "/json/version") { + response.end( + JSON.stringify({ + Browser: "node.js/v24.4.1", + "Protocol-Version": "1.3", + "V8-Version": "13.6", + }), + ); + return; + } + if (request.url === "/json/list") { + response.end( + JSON.stringify([ + target( + targetId, + options.targetUrl, + options.targetType ?? "node", + port, + ), + ...(options.additionalTargetUrl === undefined + ? [] + : [ + target( + otherTargetId, + options.additionalTargetUrl, + options.targetType ?? "node", + port, + ), + ]), + ]), + ); + return; + } + response.statusCode = 404; + response.end("{}"); + }); + const webSockets = new WebSocketServer({ noServer: true }); + http.on("upgrade", (request, socket, head) => { + if (request.url !== `/${targetId}` && request.url !== `/${otherTargetId}`) { + socket.destroy(); + return; + } + webSockets.handleUpgrade(request, socket, head, (webSocket) => + webSockets.emit("connection", webSocket, request), + ); + }); + webSockets.on("connection", (socket: WebSocket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + socket.on("message", (raw) => { + const command = parseCommand(raw.toString()); + commands.push(command); + if (options.closeOnMethod === command.method) { + socket.close(); + return; + } + socket.send(JSON.stringify({ id: command.id, result: {} })); + if (command.method === "Runtime.enable") + socket.send( + JSON.stringify({ + method: "Runtime.executionContextCreated", + params: { + context: { + id: 1, + origin: "", + name: "node[fixture]", + }, + }, + }), + ); + if (command.method === "Debugger.enable") + for (const [index, url] of ( + options.scriptUrls ?? [options.targetUrl] + ).entries()) + socket.send( + JSON.stringify({ + method: "Debugger.scriptParsed", + params: { + scriptId: String(index + 1), + url, + executionContextId: 1, + hash: `hash-${String(index)}`, + length: 100 + index, + isModule: index % 2 === 0, + }, + }), + ); + }); + }); + await new Promise((resolve, reject) => { + http.once("error", reject); + http.listen(0, "127.0.0.1", () => resolve()); + }); + const address = http.address(); + if (address === null || typeof address === "string") + throw new Error("Fake Inspector did not bind a TCP address"); + port = address.port; + return { + endpoint: `http://127.0.0.1:${String(port)}`, + targetId, + commands, + async close() { + for (const socket of sockets) socket.terminate(); + await new Promise((resolve) => webSockets.close(() => resolve())); + await new Promise((resolve, reject) => + http.close((error) => + error === undefined ? resolve() : reject(error), + ), + ); + }, + }; +}; + +const target = ( + id: string, + url: string, + type: "node" | "page", + port: number, +) => ({ + id, + type, + title: "redacted-by-provider", + url, + attached: false, + webSocketDebuggerUrl: `ws://localhost:${String(port)}/${id}`, +}); + +const parseCommand = (raw: string): InspectorCommand => { + const value: unknown = JSON.parse(raw); + if ( + typeof value !== "object" || + value === null || + typeof Reflect.get(value, "id") !== "number" || + typeof Reflect.get(value, "method") !== "string" + ) + throw new TypeError("Invalid Inspector command"); + const params = Reflect.get(value, "params"); + if (typeof params !== "object" || params === null || Array.isArray(params)) + throw new TypeError("Invalid Inspector command params"); + return { + id: Reflect.get(value, "id"), + method: Reflect.get(value, "method"), + params, + }; +}; diff --git a/tests/fixtures/hopperBridgeFacadeProbe.py b/tests/fixtures/hopperBridgeFacadeProbe.py new file mode 100644 index 000000000..aefad41ec --- /dev/null +++ b/tests/fixtures/hopperBridgeFacadeProbe.py @@ -0,0 +1,92 @@ +"""Exercise the Hopper bridge facade without importing Hopper globals.""" + +import json +from pathlib import Path +import sys + + +class FakeDocument: + def __init__(self): + self.analysis_active = True + + def getDocumentName(self): + return "fixture" + + def getExecutableFilePath(self): + return "/tmp/rea-hopper-facade-fixture" + + def getDatabaseFilePath(self): + return None + + def backgroundProcessActive(self): + return self.analysis_active + + def getCurrentAddress(self): + return 0x401000 + + +class FakeDocumentProvider: + document = FakeDocument() + + @classmethod + def getAllDocuments(cls): + return [cls.document] + + @classmethod + def getCurrentDocument(cls): + return cls.document + + +def load_bridge(path): + namespace = { + "__file__": path, + "__name__": "rea_hopper_bridge", + } + source = Path(path).read_text(encoding="utf-8") + exec(compile(source, path, "exec"), namespace) + return namespace + + +def main(): + bridge = load_bridge(sys.argv[1]) + unavailable = None + try: + bridge["_api"]() + except Exception as error: + unavailable = { + "type": type(error).__name__, + "diagnostic_type": bridge["_diagnostic_type"](error), + } + + bridge["REA_TARGET_PATH"] = "/tmp/rea-hopper-facade-fixture" + bridge["_configure_hopper_api"](FakeDocumentProvider) + current = bridge["_dispatch"]("current_document", {}) + current_address = bridge["_dispatch"]("current_address", {}) + selected = bridge["_session_document"]().getDocumentName() + + analysis_guard = None + try: + bridge["_dispatch"]("list_procedures", {"offset": 0, "limit": 1}) + except Exception as error: + analysis_guard = { + "type": type(error).__name__, + "diagnostic_type": bridge["_diagnostic_type"](error), + "message": str(error), + } + + print( + json.dumps( + { + "imported_without_hopper": unavailable, + "current_document": current, + "current_address": current_address, + "session_document": selected, + "analysis_guard": analysis_guard, + }, + sort_keys=True, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/tests/ghidraMcp.test.ts b/tests/ghidraMcp.test.ts index a221e0af4..851d08675 100644 --- a/tests/ghidraMcp.test.ts +++ b/tests/ghidraMcp.test.ts @@ -219,14 +219,10 @@ describe("Ghidra MCP and shared CLI composition", () => { }, }); - const unsupported = await mcp.callTool({ - name: "set_comment", - arguments: { address: "0x401000", comment: "not allowed" }, - }); - expect(unsupported.isError).toBe(true); - expect(unsupported.structuredContent).toMatchObject({ - error: { code: "capability_unavailable" }, - }); + const availableNames = (await mcp.listTools()).tools.map( + ({ name }) => name, + ); + expect(availableNames).not.toContain("set_comment"); expect(calls).not.toContain("set_comment"); const status = await mcp.callTool({ @@ -265,6 +261,10 @@ describe("Ghidra MCP and shared CLI composition", () => { name: "find_xrefs_to_name", available: true, }), + expect.objectContaining({ + name: "set_comment", + available: false, + }), ]), }, }); @@ -272,7 +272,7 @@ describe("Ghidra MCP and shared CLI composition", () => { await Promise.allSettled([mcp.close(), server.close()]); await session.close(); } - }); + }, 10_000); }); const sessionEvidence = (session: BinarySession, value: unknown) => { diff --git a/tests/hopperBridgeFacade.test.ts b/tests/hopperBridgeFacade.test.ts new file mode 100644 index 000000000..a7b53bbfb --- /dev/null +++ b/tests/hopperBridgeFacade.test.ts @@ -0,0 +1,45 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +const execute = promisify(execFile); +const bridgePath = new URL("../bridge/hopper_bridge.py", import.meta.url); +const probePath = new URL( + "./fixtures/hopperBridgeFacadeProbe.py", + import.meta.url, +); + +const probeResultSchema = z.strictObject({ + imported_without_hopper: z.strictObject({ + type: z.literal("CapabilityUnavailableError"), + diagnostic_type: z.literal("capability_unavailable"), + }), + current_document: z.literal("fixture"), + current_address: z.literal("0x401000"), + session_document: z.literal("fixture"), + analysis_guard: z.strictObject({ + type: z.literal("CapabilityUnavailableError"), + diagnostic_type: z.literal("capability_unavailable"), + message: z.string(), + }), +}); + +describe("Hopper API facade", () => { + it("imports without Hopper globals and gates exhaustive work during analysis", async () => { + const { stdout } = await execute( + "python3", + [probePath.pathname, bridgePath.pathname], + { + encoding: "utf8", + timeout: 3_000, + maxBuffer: 1_024 * 1_024, + }, + ); + const result = probeResultSchema.parse(JSON.parse(stdout)); + expect(result.analysis_guard.message).toContain( + "requires completed Hopper background analysis", + ); + }); +}); diff --git a/tests/hopperClient.test.ts b/tests/hopperClient.test.ts index c7af32efa..2fd88ac47 100644 --- a/tests/hopperClient.test.ts +++ b/tests/hopperClient.test.ts @@ -278,7 +278,7 @@ describe("HopperClient", () => { const results = await Promise.all( OFFICIAL_TOOL_CONTRACTS.map(({ name }) => client.callTool(name, {})), ); - expect(results).toHaveLength(34); + expect(results).toHaveLength(36); expect(results.every((result) => result.ok)).toBe(true); }); @@ -317,6 +317,19 @@ describe("HopperClient", () => { }); }); + it("projects a missing bridge API as typed capability unavailability", async () => { + const client = await startClient(); + const result = await client.callTool("capability_unavailable"); + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.error).toMatchObject({ + _tag: "AnalysisCapabilityUnavailableError", + providerId: "hopper", + operation: "capability_unavailable", + reason: "fixture API is unavailable", + }); + }); + it("cancels and ignores late responses without corrupting the session", async () => { const client = await startClient(); const controller = new AbortController(); diff --git a/tests/hopperProviderSelection.test.ts b/tests/hopperProviderSelection.test.ts index e4edabbe5..62cc52aca 100644 --- a/tests/hopperProviderSelection.test.ts +++ b/tests/hopperProviderSelection.test.ts @@ -50,6 +50,26 @@ describe("Hopper provider discovery", () => { code: "architecture_unsupported", }); }); + + it("advertises the exact bounded byte-read limit", () => { + const capabilities = provider(process.execPath).capabilities(); + expect( + capabilities.find(({ operation }) => operation === "read_bytes"), + ).toMatchObject({ + available: true, + pagination: "none", + limits: { + maxResults: null, + maxPayloadBytes: 4_096, + timeoutMs: null, + }, + }); + expect( + capabilities.find( + ({ operation }) => operation === "address_to_file_offset", + ), + ).toMatchObject({ available: true, pagination: "none" }); + }); }); const provider = (launcherPath: string): HopperProvider => { diff --git a/tests/integration/serverIntegration.test.ts b/tests/integration/serverIntegration.test.ts index ce16ebc9e..f131ab5a4 100644 --- a/tests/integration/serverIntegration.test.ts +++ b/tests/integration/serverIntegration.test.ts @@ -1,11 +1,14 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import type { CallToolResult } from "@modelcontextprotocol/server"; import { afterEach, describe, expect, it } from "vitest"; +import { z } from "zod"; import { BinarySession } from "../../src/application/BinarySession.js"; import type { AnalysisClient, AnalysisOperationPort, + AnalysisProvider, + CapabilityDescriptor, } from "../../src/application/AnalysisProvider.js"; import { AnalysisCapabilityUnavailableError, @@ -47,6 +50,47 @@ const connect = async (analysis: AnalysisOperationPort) => { return client; }; +const providerWithCapabilities = ( + operations: readonly CapabilityDescriptor["operation"][], +): AnalysisProvider => { + const identity = { id: "fixture", name: "Fixture", version: "1" }; + const capabilities: readonly CapabilityDescriptor[] = operations.map( + (operation) => ({ + provider: identity, + operation, + inputContractVersion: 1, + outputContractVersion: 1, + available: true, + reason: null, + pagination: "none", + exhaustive: true, + effects: { + mutatesArtifact: false, + launchesProcess: false, + mayShowUi: false, + mayAccessNetwork: false, + mayWriteFilesystem: false, + changesPermissions: false, + requiresRoot: false, + }, + limits: { + maxResults: null, + maxPayloadBytes: null, + timeoutMs: null, + }, + limitations: [], + }), + ); + return { + identity: () => identity, + capabilities: () => capabilities, + createClient: () => ({ + execute: () => Promise.resolve(ok(null)), + close: () => Promise.resolve(), + }), + }; +}; + const text = (result: CallToolResult): string => { const content = result.content.find((item) => item.type === "text"); if (content?.type !== "text") throw new Error("missing text result"); @@ -189,7 +233,7 @@ describe("full MCP integration with multi-tool sequences", () => { }); }); - it("preserves the complete canonical inventory with a session", async () => { + it("advertises the complete currently available inventory with a session", async () => { const session = new BinarySession( (_path) => ({ @@ -212,13 +256,32 @@ describe("full MCP integration with multi-tool sequences", () => { await client.connect(clientTransport); const listed = await client.listTools(); - expect(listed.tools).toHaveLength(TOOL_CONTRACTS.length); const names = listed.tools.map((t) => t.name); expect(names).toContain("open_binary"); expect(names).toContain("close_binary"); expect(names).toContain("binary_session"); - expect(names).toContain("binary_overview"); - expect(names).toContain("batch_decompile"); + expect(names).not.toContain("binary_overview"); + expect(names).not.toContain("batch_decompile"); + const status = structured( + await client.callTool({ + name: "binary_session", + arguments: { detail: "full" }, + }), + ); + const available = new Set( + z + .object({ + result: z.object({ + tool_availability: z.array( + z.object({ name: z.string(), available: z.boolean() }), + ), + }), + }) + .parse(status) + .result.tool_availability.filter((item) => item.available) + .map(({ name }) => name), + ); + expect(new Set(names)).toEqual(available); const contracts = new Map( TOOL_CONTRACTS.map((contract) => [contract.name, contract]), @@ -237,7 +300,7 @@ describe("full MCP integration with multi-tool sequences", () => { ); assertDescribedRootObject(tool.outputSchema, `${tool.name}.output`); } - }); + }, 10_000); it("records approved trace truncation as a deduplicated residual unknown", async () => { const analysis: AnalysisOperationPort = { @@ -254,11 +317,12 @@ describe("full MCP integration with multi-tool sequences", () => { ), }; const session = new BinarySession( - () => - ({ - execute: () => Promise.resolve(ok(null)), - close: () => Promise.resolve(), - }) satisfies AnalysisClient, + providerWithCapabilities([ + "list_strings", + "list_procedures", + "xrefs", + "resolve_containing_procedure", + ]), ); const server = createServer(analysis, session); const client = new Client({ name: "unknown-workflow", version: "1.0.0" }); @@ -267,6 +331,10 @@ describe("full MCP integration with multi-tool sequences", () => { resources.push(client, server); await server.connect(serverTransport); await client.connect(clientTransport); + await client.callTool({ + name: "open_binary", + arguments: { path: process.execPath }, + }); for (let attempt = 0; attempt < 2; attempt += 1) { const traced = await client.callTool({ @@ -310,11 +378,7 @@ describe("full MCP integration with multi-tool sequences", () => { }, }; const session = new BinarySession( - () => - ({ - execute: () => Promise.resolve(ok(null)), - close: () => Promise.resolve(), - }) satisfies AnalysisClient, + providerWithCapabilities(["procedure_pseudo_code"]), ); const server = createServer(analysis, session); const client = new Client({ name: "unavailable-unknown", version: "1" }); @@ -323,6 +387,10 @@ describe("full MCP integration with multi-tool sequences", () => { resources.push(client, server); await server.connect(serverTransport); await client.connect(clientTransport); + await client.callTool({ + name: "open_binary", + arguments: { path: process.execPath }, + }); const unavailable = await client.callTool({ name: "procedure_pseudo_code", diff --git a/tests/javascriptBundlerAdapters.test.ts b/tests/javascriptBundlerAdapters.test.ts index 93d8ac822..a941d8533 100644 --- a/tests/javascriptBundlerAdapters.test.ts +++ b/tests/javascriptBundlerAdapters.test.ts @@ -104,6 +104,40 @@ describe("Webpack and Rspack runtime adapters", () => { await rm(root, { recursive: true, force: true }); } }); + + it("recovers esbuild wrapper modules and Vite preload dependencies", async () => { + const root = await esmRuntimeFixture(); + try { + const { graph } = await reconstructJavaScriptArtifact({ + input_path: root, + }); + const commonJsModule = moduleNode(graph, "src/dep.js"); + const esmModule = moduleNode(graph, "src/core.js"); + + expect(commonJsModule?.observations[0]?.properties).toMatchObject({ + bundler: "esbuild", + runtime: "esbuild-commonjs", + module_key: "src/dep.js", + }); + expect(esmModule?.observations[0]?.properties).toMatchObject({ + bundler: "esbuild", + runtime: "esbuild-esm", + module_key: "src/core.js", + }); + expect( + graph.edges.filter( + ({ relation, properties }) => + relation === "imports" && + properties.kind === "dynamic-import" && + ["./feature.js", "./main.css"].includes( + String(properties.specifier), + ), + ), + ).toHaveLength(2); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); }); const bundlerFixture = async (): Promise => { @@ -159,6 +193,33 @@ const bundlerFixture = async (): Promise => { return root; }; +const esmRuntimeFixture = async (): Promise => { + const root = await createTestTempDirectory("rea-esm-runtime-adapters-"); + await writeFile( + join(root, "main.js"), + ` + const __vite__mapDeps = (indexes, map = __vite__mapDeps, dependencies = + (map.f || (map.f = ["./feature.js", "./main.css"]))) => + indexes.map((index) => dependencies[index]); + const require_dep = __commonJS({ + "src/dep.js"(exports, module) { + module.exports = { value: 1 }; + } + }); + const init_core = __esm({ + "src/core.js"() { + require_dep(); + } + }); + __vite__mapDeps([0, 1]); + init_core(); + `, + ); + await writeFile(join(root, "feature.js"), "export const feature = true;"); + await writeFile(join(root, "main.css"), ".app { color: green; }"); + return root; +}; + const chunk = ( graph: JavaScriptApplicationGraph, runtime: string, diff --git a/tests/javascriptRuntimeObservation.test.ts b/tests/javascriptRuntimeObservation.test.ts new file mode 100644 index 000000000..90e74e76b --- /dev/null +++ b/tests/javascriptRuntimeObservation.test.ts @@ -0,0 +1,362 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { afterEach, describe, expect, test } from "vitest"; + +import { createJavaScriptRuntimeObservationEvidence } from "../src/application/JavaScriptRuntimeObservationEvidence.js"; +import { + listJavaScriptRuntimeTargets, + observeJavaScriptRuntime, +} from "../src/application/JavaScriptRuntimeObservationService.js"; +import { createPermissionAuthority } from "../src/application/PermissionAuthority.js"; +import { reconcileJavaScriptRuntimeEvidence } from "../src/application/JavaScriptRuntimeReconciliationService.js"; +import { V8_INSPECTOR_PROVIDER_IDENTITY } from "../src/browser/V8InspectorProvider.js"; +import { V8InspectorProvider } from "../src/browser/V8InspectorProvider.js"; +import { JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE } from "../src/contracts/javascriptRuntimeReconciliationExample.js"; +import type { + JavaScriptRuntimeObservation, + ObserveJavaScriptRuntimeInput, +} from "../src/domain/javascriptRuntimeObservation.js"; +import { javascriptRuntimeReconciliationResultSchema } from "../src/domain/javascriptRuntimeReconciliationSchemas.js"; +import type { + PermissionCeiling, + PermissionGrant, +} from "../src/domain/permissionPolicy.js"; +import { startFakeV8Inspector } from "./fixtures/fakeV8Inspector.js"; + +const temporaryRoots: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryRoots.splice(0).map((path) => rm(path, { recursive: true })), + ); +}); + +describe("passive V8 Inspector provider", () => { + test("filters target locations and never retains the excluded path", async () => { + const fixture = await runtimeFixture(); + const outside = await temporaryFile("outside.js"); + const fake = await startFakeV8Inspector({ + targetUrl: pathToFileURL(fixture.entry).href, + additionalTargetUrl: pathToFileURL(outside).href, + }); + try { + const result = await new V8InspectorProvider().listTargets({ + inspector_endpoint: fake.endpoint, + allowed_file_roots: [fixture.root], + allowed_origins: [], + approved: true, + offset: 0, + limit: 100, + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.targets.total).toBe(1); + expect(result.value.excluded.outside_file_roots).toBe(1); + expect(JSON.stringify(result.value)).not.toContain(outside); + } finally { + await fake.close(); + } + }); + + test("captures only bounded metadata with two enable commands", async () => { + const fixture = await runtimeFixture(); + const outside = await temporaryFile("secret.js"); + const fake = await startFakeV8Inspector({ + targetUrl: pathToFileURL(fixture.entry).href, + scriptUrls: [ + pathToFileURL(fixture.entry).href, + "node:fs", + pathToFileURL(outside).href, + ], + }); + try { + const result = await new V8InspectorProvider().observe( + observeInput(fake.endpoint, fake.targetId, fixture.root, "node"), + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.scripts.items).toHaveLength(2); + expect(result.value.scripts.excluded.outside_file_roots).toBe(1); + expect(result.value.execution_contexts).toEqual([ + { + context_key: "1", + state: "created", + name: null, + origin: null, + }, + ]); + expect(fake.commands.map(({ method }) => method)).toEqual([ + "Runtime.enable", + "Debugger.enable", + ]); + expect(JSON.stringify(result.value)).not.toContain(outside); + expect(result.value.unavailable_without_instrumentation).toContain( + "Electron IPC messages and handlers", + ); + } finally { + await fake.close(); + } + }); + + test.each([ + ["node", "node"], + ["electron-main", "node"], + ["electron-preload", "page"], + ["electron-renderer", "page"], + ] as const)( + "admits declared %s only on its protocol target family", + async (runtimeKind, targetType) => { + const fixture = await runtimeFixture(); + const fake = await startFakeV8Inspector({ + targetUrl: pathToFileURL(fixture.entry).href, + targetType, + }); + try { + const result = await new V8InspectorProvider().observe( + observeInput(fake.endpoint, fake.targetId, fixture.root, runtimeKind), + ); + expect(result.ok).toBe(true); + } finally { + await fake.close(); + } + }, + ); + + test("marks capture truncation instead of claiming complete coverage", async () => { + const fixture = await runtimeFixture(); + const second = join(fixture.root, "second.js"); + await writeFile(second, "export {};\n"); + const fake = await startFakeV8Inspector({ + targetUrl: pathToFileURL(fixture.entry).href, + scriptUrls: [ + pathToFileURL(fixture.entry).href, + pathToFileURL(second).href, + ], + }); + try { + const input = observeInput( + fake.endpoint, + fake.targetId, + fixture.root, + "node", + ); + const result = await new V8InspectorProvider().observe({ + ...input, + limits: { ...input.limits, max_scripts: 1 }, + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.capture.truncated).toBe(true); + expect(result.value.capture.truncation_reasons).toEqual(["max_scripts"]); + expect(result.value.capture.events_dropped).toBe(1); + } finally { + await fake.close(); + } + }); + + test("produces deterministic Evidence through the authorized service", async () => { + const fixture = await runtimeFixture(); + const fake = await startFakeV8Inspector({ + targetUrl: pathToFileURL(fixture.entry).href, + }); + try { + const input = observeInput( + fake.endpoint, + fake.targetId, + fixture.root, + "node", + ); + const authority = await authorityFor(fake.endpoint, fixture.root); + const first = await observeJavaScriptRuntime( + new V8InspectorProvider(), + authority, + input, + ); + const second = await observeJavaScriptRuntime( + new V8InspectorProvider(), + authority, + input, + ); + expect(first.ok).toBe(true); + expect(second.ok).toBe(true); + if (!first.ok || !second.ok) return; + expect(second.value.evidence_id).toBe(first.value.evidence_id); + + const listed = await listJavaScriptRuntimeTargets( + new V8InspectorProvider(), + authority, + { + inspector_endpoint: fake.endpoint, + allowed_file_roots: [fixture.root], + allowed_origins: [], + approved: true, + offset: 0, + limit: 100, + }, + ); + expect(listed.ok).toBe(true); + } finally { + await fake.close(); + } + }); + + test("reconciles V8 script presence with static Application Graph Evidence", () => { + const root = "/Applications/Example.app/Contents/Resources/app"; + const observation = runtimeObservation( + `${root}/index.html`, + `${root}/renderer.js`, + ); + const runtimeEvidence = createJavaScriptRuntimeObservationEvidence( + "observe_javascript_runtime", + { + inspector_endpoint: "http://127.0.0.1:9229", + allowed_file_roots: [root], + allowed_origins: [], + target_id: "example-v8-target", + runtime_kind: "electron-main", + approved: true, + observation_ms: 100, + limits: { + max_events: 10_000, + max_scripts: 2_000, + max_execution_contexts: 1_000, + max_location_bytes: 16_384, + max_total_metadata_bytes: 4_194_304, + }, + }, + observation, + V8_INSPECTOR_PROVIDER_IDENTITY, + ); + const reconciled = reconcileJavaScriptRuntimeEvidence({ + static_layers: JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE.static_layers, + runtime_observations: [runtimeEvidence], + }); + expect(reconciled.ok).toBe(true); + if (!reconciled.ok) return; + const result = javascriptRuntimeReconciliationResultSchema.parse( + reconciled.value.normalized_result, + ); + expect(result.runtime_captures[0]?.kind).toBe("v8-inspector"); + expect(result.summary.runtime_scripts).toBe(1); + expect(result.summary.matched).toBeGreaterThan(0); + }); +}); + +const runtimeFixture = async () => { + const root = await mkdtemp(join(tmpdir(), "rea-v8-runtime-")); + temporaryRoots.push(root); + const entry = join(root, "entry.js"); + await writeFile(entry, "export const value = 1;\n"); + return { root, entry }; +}; + +const temporaryFile = async (name: string): Promise => { + const root = await mkdtemp(join(tmpdir(), "rea-v8-outside-")); + temporaryRoots.push(root); + const path = join(root, name); + await writeFile(path, "export {};\n"); + return path; +}; + +const observeInput = ( + endpoint: string, + targetId: string, + root: string, + runtimeKind: ObserveJavaScriptRuntimeInput["runtime_kind"], +): ObserveJavaScriptRuntimeInput => ({ + inspector_endpoint: endpoint, + allowed_file_roots: [root], + allowed_origins: [], + target_id: targetId, + runtime_kind: runtimeKind, + approved: true, + observation_ms: 10, + limits: { + max_events: 10_000, + max_scripts: 2_000, + max_execution_contexts: 1_000, + max_location_bytes: 16_384, + max_total_metadata_bytes: 4_194_304, + }, +}); + +const authorityFor = async (endpoint: string, root: string) => { + const scope: PermissionCeiling = { + capability: "v8_inspector_observe", + roots: [root], + executables: [], + environment_names: [], + origins: [endpoint], + network: "loopback", + mount: false, + }; + const grant: PermissionGrant = { + ...scope, + grant_id: "test:v8-inspector", + lifetime: "administrator", + operation_identity: null, + expires_at: null, + }; + const authority = await createPermissionAuthority([scope], [grant]); + if (!authority.ok) throw authority.error; + return authority.value; +}; + +const runtimeObservation = ( + targetPath: string, + scriptPath: string, +): JavaScriptRuntimeObservation => ({ + schema_version: 1, + runtime: { + product: "node.js/v24.4.1", + protocol_version: "1.3", + v8_version: "13.6", + }, + target: { + target_id: "example-v8-target", + protocol_type: "node", + attached: false, + location: { kind: "file", file_path: targetPath }, + runtime_kind: "electron-main", + runtime_kind_authority: "caller-declared-unverified", + }, + capture: { + observation_ms: 100, + events_observed: 1, + events_retained: 1, + events_dropped: 0, + metadata_bytes_retained: 100, + truncated: false, + truncation_reasons: [], + }, + scripts: { + items: [ + { + script_key: `v8_script_${"4".repeat(64)}`, + location: { kind: "file", file_path: scriptPath }, + execution_context_key: "1", + cdp_hash: "v8-hash", + length: 29, + is_module: true, + status: "observed-loaded", + }, + ], + observed_total: 1, + excluded: { + outside_file_roots: 0, + outside_origins: 0, + unsupported_location: 0, + invalid_protocol_value: 0, + }, + }, + execution_contexts: [], + directly_observed: ["Debugger.scriptParsed established script presence."], + unavailable_without_instrumentation: [ + "require/import caller-to-callee edges", + ], + unknowns: ["Capture is bounded."], + limitations: ["Passive Inspector metadata only."], +}); diff --git a/tests/javascriptRuntimeObservationCli.test.ts b/tests/javascriptRuntimeObservationCli.test.ts new file mode 100644 index 000000000..3cf460c54 --- /dev/null +++ b/tests/javascriptRuntimeObservationCli.test.ts @@ -0,0 +1,149 @@ +import { execFile } from "node:child_process"; +import { rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { promisify } from "node:util"; + +import { afterEach, describe, expect, test } from "vitest"; + +import { loadConfiguredPermissionAuthority } from "../src/application/PermissionConfiguration.js"; +import { observeJavaScriptRuntime } from "../src/application/JavaScriptRuntimeObservationService.js"; +import { V8InspectorProvider } from "../src/browser/V8InspectorProvider.js"; +import { parseConfig } from "../src/config.js"; +import { startFakeV8Inspector } from "./fixtures/fakeV8Inspector.js"; +import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; + +const execute = promisify(execFile); +const TIMEOUT_MS = 20_000; + +describe("JavaScript runtime observation CLI parity", () => { + const resources: Array<{ close(): Promise }> = []; + const temporary: string[] = []; + + afterEach(async () => { + await Promise.all(resources.splice(0).map((item) => item.close())); + await Promise.all( + temporary + .splice(0) + .map((path) => rm(path, { recursive: true, force: true })), + ); + }); + + test( + "returns the same passive Inspector Evidence contracts", + async () => { + const root = await createTestTempDirectory("rea-v8-cli-"); + temporary.push(root); + const entry = join(root, "entry.js"); + await writeFile(entry, "export const value = 1;\n"); + const inspector = await startFakeV8Inspector({ + targetUrl: pathToFileURL(entry).href, + }); + resources.push(inspector); + const environment = { + ...process.env, + REA_V8_INSPECTOR_OBSERVE_ENABLED: "true", + REA_V8_INSPECTOR_ENDPOINTS_JSON: JSON.stringify([inspector.endpoint]), + REA_V8_INSPECTOR_FILE_ROOTS_JSON: JSON.stringify([root]), + }; + + const listed = await runCli( + [ + "list-javascript-runtime-targets", + inspector.endpoint, + "--approved", + "--json", + ], + environment, + ); + expect(listed).toMatchObject({ + operation: "list_javascript_runtime_targets", + provider: { id: "rea-v8-inspector" }, + normalized_result: { + targets: { items: [{ target_id: inspector.targetId }] }, + }, + }); + + const observed = await runCli( + [ + "observe-javascript-runtime", + inspector.endpoint, + inspector.targetId, + "--runtime-kind", + "node", + "--approved", + "--observation-ms", + "10", + "--json", + ], + environment, + ); + expect(observed).toMatchObject({ + operation: "observe_javascript_runtime", + provider: { id: "rea-v8-inspector" }, + normalized_result: { + target: { target_id: inspector.targetId, runtime_kind: "node" }, + scripts: { + items: [ + expect.objectContaining({ + location: { kind: "file", file_path: entry }, + }), + ], + }, + }, + }); + const config = parseConfig(environment); + if (!config.ok) throw config.error; + const authority = await loadConfiguredPermissionAuthority(config.value); + if (!authority.ok) throw authority.error; + const direct = await observeJavaScriptRuntime( + new V8InspectorProvider(), + authority.value, + { + inspector_endpoint: inspector.endpoint, + allowed_file_roots: [root], + allowed_origins: [], + target_id: inspector.targetId, + runtime_kind: "node", + approved: true, + observation_ms: 10, + limits: { + max_events: 10_000, + max_scripts: 2_000, + max_execution_contexts: 1_000, + max_location_bytes: 16_384, + max_total_metadata_bytes: 4_194_304, + }, + }, + ); + expect(direct.ok).toBe(true); + if (direct.ok) + expect(evidenceIdFrom(observed)).toBe(direct.value.evidence_id); + }, + TIMEOUT_MS, + ); +}); + +const runCli = async ( + arguments_: readonly string[], + environment: NodeJS.ProcessEnv, +): Promise => { + const { stdout } = await execute( + process.execPath, + ["scripts/rea.mjs", ...arguments_], + { + cwd: process.cwd(), + env: environment, + maxBuffer: 16 * 1_024 * 1_024, + }, + ); + return JSON.parse(stdout); +}; + +const evidenceIdFrom = (value: unknown): string => { + if (typeof value !== "object" || value === null) + throw new TypeError("Missing CLI Evidence"); + const id = Reflect.get(value, "evidence_id"); + if (typeof id !== "string") throw new TypeError("Missing CLI Evidence ID"); + return id; +}; diff --git a/tests/javascriptRuntimeObservationMcp.test.ts b/tests/javascriptRuntimeObservationMcp.test.ts new file mode 100644 index 000000000..fa9735960 --- /dev/null +++ b/tests/javascriptRuntimeObservationMcp.test.ts @@ -0,0 +1,152 @@ +import { rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { afterEach, describe, expect, test } from "vitest"; + +import { BinarySession } from "../src/application/BinarySession.js"; +import { loadConfiguredPermissionAuthority } from "../src/application/PermissionConfiguration.js"; +import { observeJavaScriptRuntime } from "../src/application/JavaScriptRuntimeObservationService.js"; +import { V8InspectorProvider } from "../src/browser/V8InspectorProvider.js"; +import { parseConfig } from "../src/config.js"; +import { createServer } from "../src/server/createServer.js"; +import { observed } from "./fixtures/analysisExecution.js"; +import { startFakeV8Inspector } from "./fixtures/fakeV8Inspector.js"; +import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; + +describe("JavaScript runtime observation MCP tools", () => { + const resources: Array<{ close(): Promise }> = []; + const temporary: string[] = []; + + afterEach(async () => { + await Promise.all(resources.splice(0).map((item) => item.close())); + await Promise.all( + temporary + .splice(0) + .map((path) => rm(path, { recursive: true, force: true })), + ); + }); + + test("lists and observes one target as retained Evidence", async () => { + const root = await createTestTempDirectory("rea-v8-mcp-"); + temporary.push(root); + const entry = join(root, "entry.js"); + await writeFile(entry, "export const value = 1;\n"); + const inspector = await startFakeV8Inspector({ + targetUrl: pathToFileURL(entry).href, + }); + resources.push(inspector); + const config = parseConfig({ + REA_V8_INSPECTOR_OBSERVE_ENABLED: "true", + REA_V8_INSPECTOR_ENDPOINTS_JSON: JSON.stringify([inspector.endpoint]), + REA_V8_INSPECTOR_FILE_ROOTS_JSON: JSON.stringify([root]), + }); + if (!config.ok) throw config.error; + const authority = await loadConfiguredPermissionAuthority(config.value); + if (!authority.ok) throw authority.error; + const session = new BinarySession(() => ({ + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(), + })); + const server = createServer(session, session, { + javascriptRuntimeObservation: new V8InspectorProvider(), + permissionAuthority: authority.value, + availabilityPolicy: () => ({ + processCaptureEnabled: false, + evidenceFileRoots: 0, + investigationInputRoots: 0, + v8InspectorObservationEnabled: true, + }), + }); + const client = new Client({ name: "v8-mcp-test", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(client, server, session); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const listed = await client.callTool({ + name: "list_javascript_runtime_targets", + arguments: { + inspector_endpoint: inspector.endpoint, + allowed_file_roots: [root], + allowed_origins: [], + approved: true, + }, + }); + expect(listed.isError).not.toBe(true); + expect(listed.structuredContent).toMatchObject({ + result: { + targets: { items: [{ target_id: inspector.targetId }] }, + }, + }); + + const observedRuntime = await client.callTool({ + name: "observe_javascript_runtime", + arguments: { + inspector_endpoint: inspector.endpoint, + allowed_file_roots: [root], + allowed_origins: [], + target_id: inspector.targetId, + runtime_kind: "node", + approved: true, + observation_ms: 10, + }, + }); + expect(observedRuntime.isError).not.toBe(true); + expect(observedRuntime.structuredContent).toMatchObject({ + result: { + target: { + target_id: inspector.targetId, + runtime_kind: "node", + runtime_kind_authority: "caller-declared-unverified", + }, + scripts: { + items: [ + expect.objectContaining({ + location: { kind: "file", file_path: entry }, + }), + ], + }, + }, + }); + const evidenceId = evidenceIdFrom(observedRuntime.structuredContent); + expect(session.evidenceById(evidenceId)).toBeDefined(); + expect(inspector.commands.map(({ method }) => method)).toEqual([ + "Runtime.enable", + "Debugger.enable", + ]); + const direct = await observeJavaScriptRuntime( + new V8InspectorProvider(), + authority.value, + { + inspector_endpoint: inspector.endpoint, + allowed_file_roots: [root], + allowed_origins: [], + target_id: inspector.targetId, + runtime_kind: "node", + approved: true, + observation_ms: 10, + limits: { + max_events: 10_000, + max_scripts: 2_000, + max_execution_contexts: 1_000, + max_location_bytes: 16_384, + max_total_metadata_bytes: 4_194_304, + }, + }, + ); + expect(direct.ok).toBe(true); + if (direct.ok) expect(evidenceId).toBe(direct.value.evidence_id); + }); +}); + +const evidenceIdFrom = (value: unknown): string => { + if (typeof value !== "object" || value === null) + throw new TypeError("Missing structured result"); + const evidenceId = Reflect.get(value, "evidence_id"); + if (typeof evidenceId !== "string") + throw new TypeError("Missing Evidence ID"); + return evidenceId; +}; diff --git a/tests/javascriptSemanticAnalysis.test.ts b/tests/javascriptSemanticAnalysis.test.ts index e182e4d86..746a54c22 100644 --- a/tests/javascriptSemanticAnalysis.test.ts +++ b/tests/javascriptSemanticAnalysis.test.ts @@ -210,7 +210,7 @@ describe("JavaScript semantic analysis", () => { const result = alias(input, { mode: "fast" }); `); - expect(ir.schemaVersion).toBe(3); + expect(ir.schemaVersion).toBe(4); const render = onlyCallable(ir, "render"); const call = ir.callSites.find( ({ calleeCallableIds }) => calleeCallableIds[0] === render.callableId, @@ -253,6 +253,139 @@ describe("JavaScript semantic analysis", () => { expect(ir.frontiers).toEqual([]); }); + it("recovers explicit Promise ownership, chains, aggregation, and awaits", () => { + const ir = analyzeJavaScriptSemantics(` + async function run(value) { + const base = Promise.resolve(value); + const chained = base.then(work).finally(cleanup); + await chained; + Promise.resolve(value).then(work); + return Promise.all([base, Promise.resolve(value)]); + } + const settled = Promise.allSettled([Promise.resolve(1)]); + `); + + expect( + ir.promiseOperations.map( + ({ kind, method, ownership, sourceResolution }) => ({ + kind, + method, + ownership, + sourceResolution, + }), + ), + ).toEqual( + expect.arrayContaining([ + { + kind: "static", + method: "resolve", + ownership: "assigned", + sourceResolution: "complete", + }, + { + kind: "chain", + method: "then", + ownership: "chained", + sourceResolution: "complete", + }, + { + kind: "chain", + method: "finally", + ownership: "assigned", + sourceResolution: "complete", + }, + { + kind: "awaited-expression", + method: "await", + ownership: "awaited", + sourceResolution: "complete", + }, + { + kind: "chain", + method: "then", + ownership: "detached", + sourceResolution: "complete", + }, + { + kind: "aggregate", + method: "all", + ownership: "returned", + sourceResolution: "complete", + }, + { + kind: "aggregate", + method: "allSettled", + ownership: "assigned", + sourceResolution: "complete", + }, + ]), + ); + const aggregate = ir.promiseOperations.find( + ({ method }) => method === "all", + ); + expect(aggregate?.sourcePromiseIds).toHaveLength(2); + expect(aggregate?.returnSiteId).not.toBeNull(); + }); + + it("does not treat a shadowed Promise binding as the intrinsic", () => { + const ir = analyzeJavaScriptSemantics(` + function run(Promise) { + return Promise.resolve(1).then(work); + } + `); + + expect(ir.promiseOperations).toEqual([ + expect.objectContaining({ + kind: "chain", + method: "then", + ownership: "returned", + sourcePromiseIds: [], + sourceResolution: "unresolved", + }), + ]); + }); + + it("bounds Promise recovery with an explicit frontier", () => { + const ir = analyzeJavaScriptSemantics( + ` + Promise.resolve(1); + Promise.resolve(2); + Promise.resolve(3); + `, + { maxPromiseOperations: 1 }, + ); + + expect(ir.promiseOperations).toHaveLength(1); + expect(ir.coverage).toMatchObject({ + status: "truncated", + limitsReached: ["maxPromiseOperations"], + }); + }); + + it("fingerprints formatting and local-name changes identically", () => { + const left = analyzeJavaScriptSemantics(` + function calculate(value) { + const result = value + 1; + return result; + } + `); + const right = analyzeJavaScriptSemantics( + "function renamed(input){const output=input+1;return output}", + ); + const changed = analyzeJavaScriptSemantics( + "function renamed(input){const output=input+2;return output}", + ); + + expect(left.functionFingerprints).toHaveLength(1); + expect(right.functionFingerprints).toHaveLength(1); + expect(left.functionFingerprints[0]?.components).toEqual( + right.functionFingerprints[0]?.components, + ); + expect( + changed.functionFingerprints[0]?.components.literalSetSha256, + ).not.toBe(left.functionFingerprints[0]?.components.literalSetSha256); + }); + it("retains ambiguous local callees and explicit dynamic frontiers", () => { const ir = analyzeJavaScriptSemantics(` function left(value) { return value; } @@ -295,6 +428,323 @@ describe("JavaScript semantic analysis", () => { ); }); + it("recovers EventEmitter candidates and imported timer handle cancellation", () => { + const ir = analyzeJavaScriptSemantics(` + import { + setTimeout as later, + clearTimeout as cancel, + } from "node:timers"; + function run(bus, dynamicName) { + const handle = later(work, 25); + bus.on("ready", handler); + bus.once(dynamicName, handler); + bus.emit("ready"); + bus.off("ready", handler); + cancel(handle); + } + `); + + expect( + ir.eventOperations.map(({ kind, method, eventName, resolution }) => ({ + kind, + method, + eventName, + resolution, + })), + ).toEqual([ + { + kind: "register", + method: "on", + eventName: "ready", + resolution: "complete", + }, + { + kind: "register", + method: "once", + eventName: null, + resolution: "unresolved", + }, + { + kind: "dispatch", + method: "emit", + eventName: "ready", + resolution: "complete", + }, + { + kind: "remove", + method: "off", + eventName: "ready", + resolution: "complete", + }, + ]); + expect(ir.timerOperations).toEqual([ + expect.objectContaining({ + kind: "schedule", + method: "setTimeout", + delayMilliseconds: 25, + resolution: "complete", + }), + expect.objectContaining({ + kind: "cancel", + method: "clearTimeout", + delayMilliseconds: null, + resolution: "complete", + linkedTimerId: ir.timerOperations[0]?.timerId, + }), + ]); + expect(ir.functionFingerprints[0]?.components.effects).toEqual([ + "event", + "timer", + ]); + }); + + it("does not treat shadowed timer globals as Node timers", () => { + const ir = analyzeJavaScriptSemantics(` + function run(setTimeout, clearTimeout) { + const handle = setTimeout(work, 1); + clearTimeout(handle); + } + `); + + expect(ir.timerOperations).toEqual([]); + }); + + it("recovers child-process argv, env, stdio, listeners, and signals", () => { + const ir = analyzeJavaScriptSemantics(` + import { spawn as launch } from "node:child_process"; + function run() { + const child = launch( + "/bin/tool", + ["--mode", "fast"], + { env: { MODE: "test" }, stdio: ["ignore", "pipe", "pipe"] }, + ); + child.on("exit", onExit); + child.once("error", onError); + child.kill("SIGINT"); + } + `); + + expect(ir.childProcessSpawns).toEqual([ + expect.objectContaining({ + method: "spawn", + command: "/bin/tool", + argvCount: 2, + environmentSupplied: true, + stdioMode: "array", + resolution: "complete", + }), + ]); + expect( + ir.childProcessInteractions.map( + ({ kind, eventName, signalName, resolution }) => ({ + kind, + eventName, + signalName, + resolution, + }), + ), + ).toEqual([ + { + kind: "listener", + eventName: "exit", + signalName: null, + resolution: "complete", + }, + { + kind: "listener", + eventName: "error", + signalName: null, + resolution: "complete", + }, + { + kind: "signal", + eventName: null, + signalName: "SIGINT", + resolution: "complete", + }, + ]); + expect(ir.functionFingerprints[0]?.components.effects).toContain( + "child-process", + ); + }); + + it("recovers configuration, requests, response consumers, and boundaries", () => { + const ir = analyzeJavaScriptSemantics(` + import { readFileSync } from "node:fs"; + import * as http from "node:http"; + async function run(schema) { + const endpoint = process.env.API_URL ?? "https://fallback.test"; + const mode = process.argv[2]; + const raw = readFileSync("./config.json", "utf8"); + const parsed = JSON.parse(raw); + const port = Number(process.env.PORT); + const validated = schema.parse(parsed); + const response = await fetch(endpoint, { + method: "POST", + body: validated, + }); + const body = await response.json(); + http.request("https://audit.test", { body }); + return { mode, port, body }; + } + `); + + expect( + ir.configurationOperations.map(({ kind, key }) => ({ kind, key })), + ).toEqual( + expect.arrayContaining([ + { kind: "environment", key: "API_URL" }, + { kind: "default", key: "API_URL" }, + { kind: "argv", key: "2" }, + { kind: "file", key: "./config.json" }, + { kind: "environment", key: "PORT" }, + ]), + ); + expect( + ir.requestOperations.map(({ kind, method, endpoint, resolution }) => ({ + kind, + method, + endpoint, + resolution, + })), + ).toEqual( + expect.arrayContaining([ + { + kind: "request", + method: "fetch", + endpoint: null, + resolution: "partial", + }, + { + kind: "response-consumer", + method: "json", + endpoint: null, + resolution: "complete", + }, + { + kind: "request", + method: "request", + endpoint: "https://audit.test", + resolution: "complete", + }, + ]), + ); + expect( + ir.boundaryOperations.map(({ kind, method, resolution }) => ({ + kind, + method, + resolution, + })), + ).toEqual( + expect.arrayContaining([ + { kind: "parse", method: "JSON.parse", resolution: "complete" }, + { kind: "coerce", method: "Number", resolution: "complete" }, + { kind: "parse", method: "parse", resolution: "partial" }, + ]), + ); + expect(ir.functionFingerprints[0]?.components.effects).toContain("network"); + }); + + it("recovers built-in resource acquisition and exact local release", () => { + const ir = analyzeJavaScriptSemantics(` + import { open } from "node:fs/promises"; + import { connect as dial } from "node:net"; + async function run(path) { + const file = await open(path); + const socket = dial({ port: 9000 }); + await file.close(); + socket.destroy(); + } + `); + + expect( + ir.resourceOperations.map(({ kind, method, resolution }) => ({ + kind, + method, + resolution, + })), + ).toEqual([ + { kind: "acquire", method: "open", resolution: "complete" }, + { kind: "acquire", method: "connect", resolution: "complete" }, + { kind: "release", method: "close", resolution: "complete" }, + { kind: "release", method: "destroy", resolution: "complete" }, + ]); + expect(ir.functionFingerprints[0]?.components.effects).toContain( + "resource", + ); + }); + + it("bounds every added semantic effect family independently", () => { + const ir = analyzeJavaScriptSemantics( + ` + import { spawn } from "node:child_process"; + import { open } from "node:fs/promises"; + Promise.resolve(1); + bus.on("ready", handler); + setTimeout(work, 1); + spawn("/bin/tool"); + const endpoint = process.env.API_URL; + fetch("https://example.test"); + JSON.parse(raw); + open(path); + `, + { + maxPromiseOperations: 0, + maxEventOperations: 0, + maxTimerOperations: 0, + maxChildProcessOperations: 0, + maxConfigurationOperations: 0, + maxRequestOperations: 0, + maxBoundaryOperations: 0, + maxResourceOperations: 0, + maxObjectOperations: 0, + }, + ); + + expect(ir.coverage.limitsReached).toEqual( + expect.arrayContaining([ + "maxBoundaryOperations", + "maxChildProcessOperations", + "maxConfigurationOperations", + "maxEventOperations", + "maxPromiseOperations", + "maxRequestOperations", + "maxResourceOperations", + "maxTimerOperations", + "maxObjectOperations", + ]), + ); + }); + + it("recovers static object reads, writes, spreads, and destructuring", () => { + const ir = analyzeJavaScriptSemantics(` + const source = { token: "TOKEN", count: 1 }; + const { token } = source; + const copy = { ...source }; + source.count = 2; + const read = source.token; + `); + + expect( + ir.objectOperations.map(({ kind, propertyName, resolution }) => ({ + kind, + propertyName, + resolution, + })), + ).toEqual( + expect.arrayContaining([ + { + kind: "destructure", + propertyName: "token", + resolution: "complete", + }, + { kind: "spread", propertyName: null, resolution: "complete" }, + { kind: "write", propertyName: "count", resolution: "complete" }, + { kind: "read", propertyName: "token", resolution: "complete" }, + ]), + ); + }); + it("does not project positional argument flow after a spread", () => { const ir = analyzeJavaScriptSemantics(` function target(first, second, third) { return third; } diff --git a/tests/javascriptSemanticGraphBuilder.test.ts b/tests/javascriptSemanticGraphBuilder.test.ts index 735cd67ca..4e77fa8ac 100644 --- a/tests/javascriptSemanticGraphBuilder.test.ts +++ b/tests/javascriptSemanticGraphBuilder.test.ts @@ -202,7 +202,271 @@ describe("JavaScript semantic companion graph", () => { graph.coverage.families.find( ({ family }) => family === "promise-ownership", ), - ).toMatchObject({ status: "unsupported", retained_relations: 0 }); + ).toMatchObject({ status: "unknown", retained_relations: 0 }); + }); + + it("projects bounded Promise ownership and unresolved sources", () => { + const graph = graphFor(` + async function run(value) { + const owned = new Promise((resolve) => resolve(value)); + await Promise.resolve(owned); + Promise.resolve(value).then(work).finally(cleanup); + value.then(work); + return Promise.all([owned, Promise.resolve(value)]); + } + `); + + const relations = new Set(graph.relations.map(({ relation }) => relation)); + for (const expected of [ + "aggregates", + "awaits", + "chains", + "creates-promise", + "detaches-task", + "owns", + "returns-task", + ] as const) + expect(relations.has(expected)).toBe(true); + expect( + graph.nodes.filter(({ kind }) => kind === "promise"), + ).not.toHaveLength(0); + expect( + graph.nodes.find( + ({ kind, properties }) => + kind === "promise" && + properties.method === "all" && + properties.ownership === "returned", + ), + ).toBeDefined(); + expect( + graph.unknowns.some( + ({ family, reason }) => + family === "promise-ownership" && reason === "ambiguous-target", + ), + ).toBe(true); + expect( + graph.coverage.families.find( + ({ family }) => family === "promise-ownership", + ), + ).toMatchObject({ status: "partial" }); + }); + + it("keeps duplicate function fingerprints ambiguous", () => { + const graph = graphFor(` + function first(value) { return value + 1; } + function second(input) { return input + 1; } + `); + + expect(graph.fingerprints).toHaveLength(2); + const digest = graph.fingerprints[0]?.fingerprint_sha256; + expect(digest).toBeDefined(); + expect(graph.fingerprints[1]?.fingerprint_sha256).toBe(digest); + if (digest === undefined) throw new Error("Expected fingerprint"); + const query = queryJavaScriptSemanticGraph(graph, { + seed: { kind: "function", fingerprint_sha256: digest }, + direction: "forward-influence", + }); + expect(query.status).toBe("ambiguous"); + expect(query.summary.total_seed_matches).toBe(2); + }); + + it("projects literal events and exact timer cancellation", () => { + const graph = graphFor(` + function run(bus, dynamicName) { + const handle = setInterval(tick, 10); + bus.on("ready", handler); + bus.once(dynamicName, handler); + bus.emit("ready"); + bus.removeListener("ready", handler); + clearInterval(handle); + } + `); + + const relations = new Set(graph.relations.map(({ relation }) => relation)); + for (const expected of [ + "cancels-timer", + "dispatches-candidate", + "registers-listener", + "removes-listener", + "schedules-timer", + ] as const) + expect(relations.has(expected)).toBe(true); + expect( + graph.nodes.find( + ({ kind, properties }) => + kind === "event" && properties.event_name === "ready", + ), + ).toBeDefined(); + expect( + graph.unknowns.some( + ({ family, detail }) => + family === "event" && detail.includes("Dynamic once event name"), + ), + ).toBe(true); + expect( + graph.coverage.families.find(({ family }) => family === "event"), + ).toMatchObject({ status: "partial" }); + expect( + graph.coverage.families.find(({ family }) => family === "timer"), + ).toMatchObject({ status: "partial" }); + }); + + it("projects child-process creation, I/O, listeners, and signals", () => { + const graph = graphFor(` + import { spawn } from "node:child_process"; + function run(command) { + const child = spawn( + command, + ["--mode", "fast"], + { env: process.env, stdio: "pipe" }, + ); + child.on("exit", onExit); + child.once("error", onError); + child.kill("SIGTERM"); + } + `); + + const relations = new Set(graph.relations.map(({ relation }) => relation)); + for (const expected of [ + "connects-stdio", + "forwards-signal", + "listens-error", + "listens-exit", + "spawns", + "supplies-argv", + "supplies-env", + ] as const) + expect(relations.has(expected)).toBe(true); + expect( + graph.nodes.find( + ({ kind, properties }) => + kind === "child-process" && + properties.method === "spawn" && + properties.command === null, + ), + ).toBeDefined(); + expect( + graph.unknowns.some( + ({ family, detail }) => + family === "child-process" && + detail.includes("command remains unresolved"), + ), + ).toBe(true); + expect( + graph.coverage.families.find(({ family }) => family === "child-process"), + ).toMatchObject({ status: "partial" }); + }); + + it("projects config precedence, request fields, and boundaries", () => { + const graph = graphFor(` + import { readFileSync } from "node:fs"; + async function run(schema) { + const endpoint = process.env.API_URL ?? "https://fallback.test"; + const raw = readFileSync("./config.json", "utf8"); + const parsed = JSON.parse(raw); + const port = Number(process.argv[2]); + const validated = schema.parse(parsed); + const response = await fetch(endpoint, { + method: "POST", + body: validated, + }); + return response.json(); + } + `); + + const relations = new Set(graph.relations.map(({ relation }) => relation)); + for (const expected of [ + "coerces", + "constructs-request", + "consumed-by", + "defaults", + "overrides", + "parses", + "reads-argv", + "reads-config", + "reads-environment", + "supplies-request-field", + ] as const) + expect(relations.has(expected)).toBe(true); + expect( + graph.nodes.find( + ({ kind, properties }) => + kind === "request" && properties.method === "fetch", + ), + ).toBeDefined(); + expect( + graph.nodes.filter(({ kind }) => kind === "boundary"), + ).not.toHaveLength(0); + expect( + graph.coverage.families.find(({ family }) => family === "configuration"), + ).toMatchObject({ status: "partial" }); + expect( + graph.coverage.families.find(({ family }) => family === "request"), + ).toMatchObject({ status: "partial" }); + expect( + graph.coverage.families.find(({ family }) => family === "boundary"), + ).toMatchObject({ status: "partial" }); + }); + + it("projects built-in resource acquisition and release", () => { + const graph = graphFor(` + import { open } from "node:fs/promises"; + import { connect } from "node:net"; + async function run(path) { + const file = await open(path); + const socket = connect({ port: 9000 }); + await file.close(); + socket.end(); + } + `); + + const relations = new Set(graph.relations.map(({ relation }) => relation)); + expect(relations.has("acquires")).toBe(true); + expect(relations.has("releases")).toBe(true); + expect(graph.nodes.filter(({ kind }) => kind === "resource")).toHaveLength( + 2, + ); + expect( + graph.coverage.families.find( + ({ family }) => family === "resource-lifecycle", + ), + ).toMatchObject({ status: "partial" }); + }); + + it("projects literal seeds and static object flow", () => { + const graph = graphFor(` + const source = { token: "TOKEN", count: 1 }; + const { token } = source; + const copy = { ...source }; + source.count = 2; + const read = source.token; + `); + + const relations = new Set(graph.relations.map(({ relation }) => relation)); + for (const expected of [ + "destructures", + "reads-property", + "spreads", + "writes-property", + ] as const) + expect(relations.has(expected)).toBe(true); + expect( + graph.nodes.find( + ({ kind, properties }) => + kind === "property-slot" && properties.name === "token", + ), + ).toBeDefined(); + const literal = queryJavaScriptSemanticGraph(graph, { + seed: { kind: "literal", value: "TOKEN" }, + direction: "forward-influence", + }); + expect(literal.status).toBe("ambiguous"); + expect(literal.summary.total_seed_matches).toBe(3); + const property = queryJavaScriptSemanticGraph(graph, { + seed: { kind: "property", name: "token" }, + direction: "forward-influence", + }); + expect(property.status).toBe("found"); }); it("does not promote transformed return expressions to resolved aliases", () => { diff --git a/tests/mcpDoctor.test.ts b/tests/mcpDoctor.test.ts index f78090257..3035bb907 100644 --- a/tests/mcpDoctor.test.ts +++ b/tests/mcpDoctor.test.ts @@ -32,14 +32,20 @@ describe("production MCP doctor", () => { adapter: "production-stdio", inventory: { tools: { - expected: CATALOG_IDENTITY.counts.mcp_tools, - observed: CATALOG_IDENTITY.counts.mcp_tools, + expected: expect.any(Number), + observed: expect.any(Number), missing: [], unexpected: [], }, }, request_flow: { tool: "binary_session", ok: true }, }); + expect(result.inventory?.tools.expected).toBe( + result.inventory?.tools.observed, + ); + expect(result.inventory?.tools.expected).toBeLessThan( + CATALOG_IDENTITY.counts.mcp_tools, + ); }, 30_000); it("reports exact missing, unexpected, and duplicate inventory names", () => { diff --git a/tests/officialTools.test.ts b/tests/officialTools.test.ts index f6bc450df..991ac8e14 100644 --- a/tests/officialTools.test.ts +++ b/tests/officialTools.test.ts @@ -33,6 +33,8 @@ const VALID_INPUTS: Readonly< procedure_callers: { procedure: "main" }, procedure_info: { procedure: "main" }, read_function_instructions: { procedure: "main" }, + read_bytes: { address: "0x1000", length: 4 }, + address_to_file_offset: { address: "0x1000" }, procedure_references: { procedure: "main" }, procedure_pseudo_code: { procedure: "main" }, resolve_containing_procedure: { address: "0x1000" }, @@ -130,6 +132,16 @@ const outputFor = (name: string): JsonValue => { instruction_scan_truncated: false, limitations: ["Provider-specific instruction text."], }; + if (name === "read_bytes") + return { + address: "0x1000", + requested_bytes: 4, + returned_bytes: 4, + bytes_hex: "c3c3c3c3", + complete: true, + }; + if (name === "address_to_file_offset") + return { address: "0x1000", file_offset: 0 }; if (name === "procedure_references") return { procedure: { address: "0x1000", name: "main" }, diff --git a/tests/playwrightBrowserScenarioProvider.test.ts b/tests/playwrightBrowserScenarioProvider.test.ts new file mode 100644 index 000000000..49bccde50 --- /dev/null +++ b/tests/playwrightBrowserScenarioProvider.test.ts @@ -0,0 +1,388 @@ +import { describe, expect, it } from "vitest"; + +import type { BrowserScenarioSessionPort } from "../src/browser/BrowserScenarioSessionPort.js"; +import { PlaywrightBrowserScenarioProvider } from "../src/browser/PlaywrightBrowserScenarioProvider.js"; +import { BrowserScenarioCaptureBudget } from "../src/browser/PlaywrightScenarioArtifacts.js"; +import { sanitizeBrowserUrl } from "../src/domain/browserObservation.js"; +import { + browserScenarioSchema, + type BrowserScenario, + type BrowserScenarioAction, +} from "../src/domain/browserScenario.js"; +import { + browserStepArtifactsSchema, + type BrowserScenarioEvent, + type BrowserStepArtifacts, +} from "../src/domain/browserScenarioCapture.js"; + +const scenario = ( + options: { + readonly mode?: "launch" | "connect"; + readonly captures?: readonly ( + | "screenshot" + | "dom" + | "accessibility" + | "url" + | "history" + | "storage" + )[]; + readonly events?: readonly ( + | "console" + | "page-errors" + | "network" + | "websockets" + | "frames" + | "workers" + | "popups" + | "downloads" + )[]; + readonly actions?: number; + } = {}, +): BrowserScenario => + browserScenarioSchema.parse({ + schema_version: 1, + browser: + options.mode === "connect" + ? { + mode: "connect", + cdp_endpoint: "http://127.0.0.1:9222", + target_id: "page-1", + ownership: "external", + cleanup: "disconnect-only", + } + : { + mode: "launch", + executable_path: "/opt/chromium", + headless: true, + user_data: "temporary-owned", + cleanup: "close-and-delete-profile", + }, + start_url: { url: "https://app.example.test/" }, + allowed_origins: ["https://app.example.test"], + environment: { + viewport: { width: 1_280, height: 720 }, + locale: "en-US", + timezone: "UTC", + color_scheme: "light", + reduced_motion: "reduce", + service_workers: "block", + }, + actions: Array.from({ length: options.actions ?? 1 }, (_, index) => ({ + step_id: `wait_${index}`, + action: "wait_for_timeout", + duration_ms: 1, + })), + storage: {}, + request_replay: { mode: "disabled" }, + secrets: [], + redaction: { + secret_values: "replace-with-secret-reference", + query_parameter_names: [], + }, + capture: { + after_each_step: options.captures ?? ["url"], + at_end: [], + events: options.events ?? [], + }, + limits: { + max_duration_ms: 10_000, + action_timeout_ms: 1_000, + navigation_timeout_ms: 1_000, + max_events: 100, + max_frames: 10, + max_workers: 10, + max_popups: 10, + max_websockets: 10, + max_dom_nodes: 100, + max_accessibility_nodes: 100, + max_screenshots: 2, + max_screenshot_bytes: 1_024, + max_storage_entries: 10, + max_total_metadata_bytes: 16_384, + }, + approved: true, + }); + +type SnapshotKind = BrowserScenario["capture"]["after_each_step"][number]; + +const artifactState = ( + kind: SnapshotKind, + requested: ReadonlySet, + incomplete: boolean, +) => { + if (!requested.has(kind)) return { state: "not_requested" as const }; + if (kind === "url") + return { + state: "captured" as const, + value: sanitizeBrowserUrl("https://app.example.test/current"), + }; + if (incomplete && kind === "screenshot") + return { + state: "truncated" as const, + observed: 2_048, + retained: 0, + reason: "fixture limit", + }; + return { state: "missing" as const, reason: "fixture unavailable" }; +}; + +class FakeSession implements BrowserScenarioSessionPort { + readonly product = "Fake Chromium"; + readonly version = "1"; + readonly initialUrl = "about:blank"; + readonly processOwnership: "provider-owned" | "external"; + closeCalls = 0; + performCalls = 0; + captureCalls = 0; + eventTruncations: ( + | "events" + | "frames" + | "workers" + | "popups" + | "websockets" + )[] = []; + onPerform: (() => void) | undefined; + private step = 0; + + constructor( + readonly mode: "launch" | "connect", + private readonly incomplete = false, + private readonly failAction = false, + private readonly failCapture = false, + ) { + this.processOwnership = mode === "launch" ? "provider-owned" : "external"; + } + + currentUrl() { + return "https://app.example.test/current"; + } + + setStep(index: number) { + this.step = index; + } + + nextEventSequence() { + return 1; + } + + lastEventSequence() { + return 0; + } + + events(): { + readonly retained: number; + readonly dropped: number; + readonly items: readonly BrowserScenarioEvent[]; + } { + return { retained: 0, dropped: 0, items: [] }; + } + + eventTruncationSections() { + return this.eventTruncations; + } + + async perform( + _action: BrowserScenarioAction, + _timeout: number, + signal?: AbortSignal, + ) { + this.performCalls += 1; + this.onPerform?.(); + if (signal?.aborted === true) throw new Error("request cancelled"); + if (this.failAction) throw new Error("fixture secret action failure"); + } + + capture( + requested: ReadonlySet, + _budget: BrowserScenarioCaptureBudget, + ): Promise { + this.captureCalls += 1; + if (this.failCapture) return Promise.reject(new Error("capture failed")); + return Promise.resolve( + browserStepArtifactsSchema.parse({ + screenshot: artifactState("screenshot", requested, this.incomplete), + dom: artifactState("dom", requested, this.incomplete), + accessibility: artifactState( + "accessibility", + requested, + this.incomplete, + ), + url: artifactState("url", requested, this.incomplete), + history: artifactState("history", requested, this.incomplete), + storage: artifactState("storage", requested, this.incomplete), + }), + ); + } + + close() { + this.closeCalls += 1; + return Promise.resolve( + this.mode === "launch" + ? ("terminated-owned-process" as const) + : ("disconnected-external" as const), + ); + } + + redactError(error: unknown) { + return error instanceof Error + ? error.message.replaceAll("secret", "[REDACTED]") + : "fixture failure"; + } +} + +describe("PlaywrightBrowserScenarioProvider", () => { + it("captures initial and action steps and closes its launched session", async () => { + const session = new FakeSession("launch"); + const provider = new PlaywrightBrowserScenarioProvider({ + open: () => Promise.resolve(session), + }); + const result = await provider.captureScenario(scenario()); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.steps.map(({ step_index }) => step_index)).toEqual([ + 0, 1, + ]); + expect(result.value.browser).toMatchObject({ + process_ownership: "provider-owned", + cleanup: "terminated-owned-process", + }); + expect(result.value.completeness).toMatchObject({ + status: "complete", + equality_eligible: true, + }); + expect(session.closeCalls).toBe(1); + }); + + it("makes missing and truncated captures ineligible for equality", async () => { + const session = new FakeSession("launch", true); + const provider = new PlaywrightBrowserScenarioProvider({ + open: () => Promise.resolve(session), + }); + const result = await provider.captureScenario( + scenario({ captures: ["screenshot", "dom"] }), + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.completeness).toMatchObject({ + status: "truncated", + equality_eligible: false, + missing_sections: ["dom"], + truncated_sections: ["screenshot"], + }); + }); + + it("makes event limit truncation ineligible for equality", async () => { + const session = new FakeSession("launch"); + session.eventTruncations = ["events", "websockets"]; + const provider = new PlaywrightBrowserScenarioProvider({ + open: () => Promise.resolve(session), + }); + const result = await provider.captureScenario( + scenario({ events: ["websockets"] }), + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.completeness).toMatchObject({ + status: "truncated", + equality_eligible: false, + truncated_sections: ["events", "websockets"], + }); + }); + + it("records one failed step, cancels later actions, and still cleans up", async () => { + const session = new FakeSession("launch", false, true); + const provider = new PlaywrightBrowserScenarioProvider({ + open: () => Promise.resolve(session), + }); + const result = await provider.captureScenario(scenario({ actions: 2 })); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.steps.map(({ status }) => status)).toEqual([ + "completed", + "failed", + "cancelled", + ]); + expect(result.value.steps[1]?.error).toBe( + "fixture [REDACTED] action failure", + ); + expect(session.performCalls).toBe(1); + expect(session.closeCalls).toBe(1); + expect(result.value.completeness.equality_eligible).toBe(false); + }); + + it("disconnects external CDP sessions and marks pre-attach events missing", async () => { + const session = new FakeSession("connect"); + const provider = new PlaywrightBrowserScenarioProvider({ + open: () => Promise.resolve(session), + }); + const result = await provider.captureScenario( + scenario({ mode: "connect" }), + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.browser).toMatchObject({ + process_ownership: "external", + cleanup: "disconnected-external", + }); + expect(result.value.completeness).toMatchObject({ + status: "incomplete", + equality_eligible: false, + missing_sections: ["events"], + }); + expect(session.closeCalls).toBe(1); + }); + + it("closes the session when initial capture fails", async () => { + const session = new FakeSession("launch", false, false, true); + const provider = new PlaywrightBrowserScenarioProvider({ + open: () => Promise.resolve(session), + }); + const result = await provider.captureScenario(scenario()); + expect(result.ok).toBe(false); + expect(session.closeCalls).toBe(1); + }); + + it("does not open a session for an already-cancelled request", async () => { + let opens = 0; + const provider = new PlaywrightBrowserScenarioProvider({ + open: () => { + opens += 1; + return Promise.resolve(new FakeSession("launch")); + }, + }); + const controller = new AbortController(); + controller.abort(); + const result = await provider.captureScenario(scenario(), { + signal: controller.signal, + }); + expect(result.ok).toBe(false); + expect(opens).toBe(0); + }); + + it("cancels an active action and still closes the session", async () => { + const controller = new AbortController(); + const session = new FakeSession("launch"); + session.onPerform = () => controller.abort(); + const provider = new PlaywrightBrowserScenarioProvider({ + open: () => Promise.resolve(session), + }); + const result = await provider.captureScenario(scenario(), { + signal: controller.signal, + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.steps[1]?.status).toBe("cancelled"); + expect(session.closeCalls).toBe(1); + }); +}); + +describe("BrowserScenarioCaptureBudget", () => { + it("enforces cumulative screenshot and metadata limits", () => { + const budget = new BrowserScenarioCaptureBudget(1, 10); + expect(budget.claimScreenshot()).toBe(true); + expect(budget.claimScreenshot()).toBe(false); + expect(budget.claimMetadata(6)).toBe(true); + expect(budget.claimMetadata(5)).toBe(false); + expect(budget.claimMetadata(4)).toBe(true); + }); +}); diff --git a/tests/playwrightExecutionBoundary.test.ts b/tests/playwrightExecutionBoundary.test.ts new file mode 100644 index 000000000..6c31a6561 --- /dev/null +++ b/tests/playwrightExecutionBoundary.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { withPlaywrightExecutionBoundary } from "../src/browser/PlaywrightExecutionBoundary.js"; +import { + AnalysisCancelledError, + AnalysisTimeoutError, +} from "../src/domain/errors.js"; + +describe("withPlaywrightExecutionBoundary", () => { + afterEach(() => vi.useRealTimers()); + + it("rejects work that exceeds its absolute remaining duration", async () => { + vi.useFakeTimers(); + const result = withPlaywrightExecutionBoundary( + () => new Promise(() => undefined), + 25, + ); + const rejection = + expect(result).rejects.toBeInstanceOf(AnalysisTimeoutError); + await vi.advanceTimersByTimeAsync(25); + await rejection; + }); + + it("rejects when the caller cancels active work", async () => { + const controller = new AbortController(); + const result = withPlaywrightExecutionBoundary( + () => new Promise(() => undefined), + 1_000, + controller.signal, + ); + controller.abort(); + await expect(result).rejects.toBeInstanceOf(AnalysisCancelledError); + }); +}); diff --git a/tests/playwrightScenarioEvents.test.ts b/tests/playwrightScenarioEvents.test.ts new file mode 100644 index 000000000..82d9624e1 --- /dev/null +++ b/tests/playwrightScenarioEvents.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import type { Page } from "playwright-core"; + +import { BrowserScenarioSecrets } from "../src/browser/BrowserScenarioSecrets.js"; +import { BrowserScenarioCaptureBudget } from "../src/browser/PlaywrightScenarioArtifacts.js"; +import { PlaywrightScenarioEvents } from "../src/browser/PlaywrightScenarioEvents.js"; +import { browserScenarioSchema } from "../src/domain/browserScenario.js"; + +describe("PlaywrightScenarioEvents", () => { + it("bounds oversized page errors before validating captured events", () => { + const listeners = new Map void>(); + const page = { + url: () => "https://app.example.test/", + on: (name: string, listener: (value: Error) => void) => { + listeners.set(name, listener); + }, + } as unknown as Page; + const scenario = browserScenarioSchema.parse({ + schema_version: 1, + browser: { + mode: "launch", + executable_path: "/opt/chromium", + headless: true, + user_data: "temporary-owned", + cleanup: "close-and-delete-profile", + }, + start_url: { url: "https://app.example.test/" }, + allowed_origins: ["https://app.example.test"], + environment: { + viewport: { width: 1_280, height: 720 }, + locale: "en-US", + timezone: "UTC", + color_scheme: "light", + reduced_motion: "reduce", + service_workers: "block", + }, + actions: [ + { + step_id: "wait", + action: "wait_for_timeout", + duration_ms: 1, + }, + ], + storage: {}, + request_replay: { mode: "disabled" }, + secrets: [], + redaction: { + secret_values: "replace-with-secret-reference", + query_parameter_names: [], + }, + capture: { + after_each_step: [], + at_end: [], + events: ["page-errors"], + }, + limits: { + max_duration_ms: 10_000, + action_timeout_ms: 1_000, + navigation_timeout_ms: 1_000, + max_events: 10, + max_frames: 10, + max_workers: 10, + max_popups: 10, + max_websockets: 10, + max_dom_nodes: 100, + max_accessibility_nodes: 100, + max_screenshots: 1, + max_screenshot_bytes: 1_024, + max_storage_entries: 10, + max_total_metadata_bytes: 1_000_000, + }, + approved: true, + }); + const secrets = BrowserScenarioSecrets.resolve(scenario, {}); + if (secrets === undefined) throw new Error("Expected resolved secrets"); + const events = new PlaywrightScenarioEvents({ + page, + enabled: new Set(["page-errors"]), + limits: scenario.limits, + secrets, + budget: new BrowserScenarioCaptureBudget(1, 1_000_000), + allowedOrigins: scenario.allowed_origins, + }); + const error = new Error("m".repeat(70_000)); + error.stack = "s".repeat(300_000); + + expect(() => listeners.get("pageerror")?.(error)).not.toThrow(); + expect(events.result().items[0]).toMatchObject({ + kind: "page-error", + message: "m".repeat(65_536), + stack: "s".repeat(262_144), + }); + }); +}); diff --git a/tests/processCapture.test.ts b/tests/processCapture.test.ts index 2b5a1f142..7ec32646d 100644 --- a/tests/processCapture.test.ts +++ b/tests/processCapture.test.ts @@ -1558,6 +1558,7 @@ describe("process capture adapter", () => { }, actions: [ { type: "send_input", data: "accepted\n", sensitive: true }, + { type: "close_stdin" }, ], target: { kind: "goto", state: "finishing" }, }, @@ -1620,6 +1621,13 @@ describe("process capture adapter", () => { outcome: "dispatched", }), ); + expect(result.value.interaction_events).toContainEqual( + expect.objectContaining({ + type: "stdin_close", + data: "", + outcome: "dispatched", + }), + ); expect(result.value.frames.map(({ data }) => data).join("")).toContain( "Done", ); diff --git a/tests/processOwnership.test.ts b/tests/processOwnership.test.ts index 6cca3295d..0dfe2666d 100644 --- a/tests/processOwnership.test.ts +++ b/tests/processOwnership.test.ts @@ -70,6 +70,9 @@ describe("owned process-group cleanup", () => { "darwin", ), ).toBe(true); + expect( + matchesOwnedProcessCommand("(node)", "/opt/node/bin/node", "darwin"), + ).toBe(true); expect( matchesOwnedProcessCommand( "node /tmp/fake-launcher.mjs", @@ -84,6 +87,9 @@ describe("owned process-group cleanup", () => { "linux", ), ).toBe(false); + expect( + matchesOwnedProcessCommand("(node)", "/opt/node/bin/node", "linux"), + ).toBe(false); }); it("signals only a group whose every member carries the run token", async () => { @@ -441,6 +447,35 @@ describe("owned process-group cleanup", () => { expect(signalGroup).not.toHaveBeenCalled(); }); + it("accepts a member that exits during ownership revalidation", async () => { + const liveLauncher = { + pid: 100, + parentPid: 1, + processGroupId: 100, + state: "S", + command: "fixture", + }; + const listProcesses = vi + .fn() + .mockResolvedValueOnce([liveLauncher]) + .mockResolvedValue([]); + const signalGroup = vi.fn(); + const adapter: ProcessOwnershipHost = { + listProcesses, + environment: () => + Promise.reject(new Error("process exited before environment read")), + signalGroup, + }; + + await expect(cleanupOwnedProcessGroup(ownership, adapter)).resolves.toEqual( + { + cleaned: true, + signaled: false, + }, + ); + expect(signalGroup).not.toHaveBeenCalled(); + }); + it("ignores exited zombie members during live ownership checks", async () => { const environment = vi.fn((pid: number) => Promise.resolve(pid === 101 ? {} : { REA_PROCESS_RUN_ID: "run-token" }), diff --git a/tests/processReactiveCoordinator.test.ts b/tests/processReactiveCoordinator.test.ts index ba022b251..58e138565 100644 --- a/tests/processReactiveCoordinator.test.ts +++ b/tests/processReactiveCoordinator.test.ts @@ -114,6 +114,7 @@ const createHarness = (scenario: ProcessReactiveScenario) => { write: (data: string) => terminalCalls.push(`write:${data}`), resize: (columns: number, rows: number) => terminalCalls.push(`resize:${String(columns)}x${String(rows)}`), + closeInput: () => terminalCalls.push("close-input"), kill: (signal: "SIGINT" | "SIGTERM" | "SIGKILL") => terminalCalls.push(`kill:${signal}`), }), @@ -223,6 +224,7 @@ describe("process reactive coordinator", () => { actions: [ { type: "send_input", data: "secret", sensitive: true }, { type: "resize", columns: 100, rows: 40 }, + { type: "close_stdin" }, { type: "send_signal", target: { kind: "root" }, @@ -247,11 +249,13 @@ describe("process reactive coordinator", () => { "write:secret", "resize:100x40", "render:100x40", + "close-input", "kill:SIGINT", ]); expect(harness.interactions.map(({ data }) => data)).toEqual([ "", "100x40", + "", "SIGINT", ]); expect(harness.checkpoints).toHaveLength(1); @@ -261,6 +265,7 @@ describe("process reactive coordinator", () => { "obs.interaction_events.0", "obs.interaction_events.1", "obs.interaction_events.2", + "obs.interaction_events.3", "obs.filesystem_checkpoints.0", ]); await harness.coordinator.close(); diff --git a/tests/processReactiveRuntime.test.ts b/tests/processReactiveRuntime.test.ts index 953d85717..f27483c23 100644 --- a/tests/processReactiveRuntime.test.ts +++ b/tests/processReactiveRuntime.test.ts @@ -112,7 +112,9 @@ const succeededEffect = (action: ProcessReactiveAction, order: number) => ({ ? "input" : action.type === "resize" ? "resize" - : "signal", + : action.type === "close_stdin" + ? "stdin_close" + : "signal", data: action.type === "send_input" ? action.sensitive @@ -120,7 +122,9 @@ const succeededEffect = (action: ProcessReactiveAction, order: number) => ({ : action.data : action.type === "resize" ? `${String(action.columns)}x${String(action.rows)}` - : action.signal, + : action.type === "close_stdin" + ? "" + : action.signal, outcome: "dispatched", }), }); diff --git a/tests/productCatalog.test.ts b/tests/productCatalog.test.ts index 0674e1712..9e6f0f5f8 100644 --- a/tests/productCatalog.test.ts +++ b/tests/productCatalog.test.ts @@ -18,6 +18,8 @@ import { } from "../src/application/InvestigationProviders.js"; import { CDP_BROWSER_PROVIDER_IDENTITY } from "../src/browser/CdpBrowserProvider.js"; import { CDP_ELECTRON_PROVIDER_IDENTITY } from "../src/browser/CdpElectronProvider.js"; +import { PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY } from "../src/browser/PlaywrightBrowserScenarioProvider.js"; +import { V8_INSPECTOR_PROVIDER_IDENTITY } from "../src/browser/V8InspectorProvider.js"; import { CLI_COMMAND_NAMES } from "../src/cliCommandNames.js"; import { createCli } from "../src/cli.js"; import { TOOL_CONTRACTS } from "../src/contracts/toolContracts.js"; @@ -76,7 +78,9 @@ describe("canonical product catalog", () => { MANAGED_STATIC_PROVIDER, MANAGED_WORKFLOW_PROVIDER, CDP_BROWSER_PROVIDER_IDENTITY, + PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY, CDP_ELECTRON_PROVIDER_IDENTITY, + V8_INSPECTOR_PROVIDER_IDENTITY, JAVASCRIPT_APPLICATION_PROVIDER, JAVASCRIPT_RUNTIME_RECONCILIATION_PROVIDER, JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER, @@ -112,9 +116,12 @@ describe("canonical product catalog", () => { ({ id }) => id === JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER.id, )?.capabilities, ).toEqual([ + "build_reconstruction_obligation_ledger", "commit_reconstruction_coverage", "compare_application_versions", "compare_javascript_export_shapes", + "compare_source_to_bundle", + "evaluate_reconstruction_readiness", "execute_node_characterization", "prepare_node_characterization", "query_reconstruction_coverage", diff --git a/tests/promptMcp.test.ts b/tests/promptMcp.test.ts index d2c0fbb2d..47d8bcf94 100644 --- a/tests/promptMcp.test.ts +++ b/tests/promptMcp.test.ts @@ -10,7 +10,6 @@ import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; import type { AnalysisClient } from "../src/application/AnalysisProvider.js"; import { BinarySession } from "../src/application/BinarySession.js"; import { PROMPT_CONTRACTS } from "../src/contracts/promptContracts.js"; -import { TOOL_CONTRACTS } from "../src/contracts/toolContracts.js"; import { createEvidence } from "../src/domain/evidence.js"; import { createServer } from "../src/server/createServer.js"; import { registerGuidedPrompts } from "../src/server/registerPrompts.js"; @@ -36,6 +35,7 @@ describe("guided prompts over MCP", () => { const client = await connect(createServer(session, session)); resources.push(session); + const toolsBeforePrompts = (await client.listTools()).tools; const prompts = await client.listPrompts(); expect(prompts.prompts.map(({ name }) => name)).toEqual( PROMPT_CONTRACTS.map(({ name }) => name), @@ -56,9 +56,7 @@ describe("guided prompts over MCP", () => { ), ); } - expect((await client.listTools()).tools).toHaveLength( - TOOL_CONTRACTS.length, - ); + expect((await client.listTools()).tools).toEqual(toolsBeforePrompts); const result = await client.getPrompt({ name: "investigate_feature", arguments: { diff --git a/tests/providerArchitecture.test.ts b/tests/providerArchitecture.test.ts index be1cbe89a..c18abad65 100644 --- a/tests/providerArchitecture.test.ts +++ b/tests/providerArchitecture.test.ts @@ -78,7 +78,7 @@ describe("provider-neutral architecture", () => { if (!config.ok) return; const provider = new HopperProvider(config.value, silentLogger); const capabilities = provider.capabilities(); - expect(capabilities).toHaveLength(35); + expect(capabilities).toHaveLength(37); expect(new Set(capabilities.map(({ operation }) => operation)).size).toBe( capabilities.length, ); diff --git a/tests/readmeConsistency.test.ts b/tests/readmeConsistency.test.ts index 59d5b361e..b01bc59df 100644 --- a/tests/readmeConsistency.test.ts +++ b/tests/readmeConsistency.test.ts @@ -11,8 +11,9 @@ import { NATIVE_TOOL_CONTRACTS } from "../src/contracts/nativeToolContracts.js"; import { ARTIFACT_TOOL_CONTRACTS } from "../src/contracts/artifactToolContracts.js"; import { MANAGED_TOOL_CONTRACTS } from "../src/contracts/managedToolContracts.js"; import { MANAGED_WORKFLOW_TOOL_CONTRACTS } from "../src/contracts/managedWorkflowToolContracts.js"; -import { BROWSER_TOOL_CONTRACTS } from "../src/contracts/browserToolContracts.js"; +import { BROWSER_PROVIDER_TOOL_CONTRACTS } from "../src/contracts/browserProviderToolContracts.js"; import { ELECTRON_TOOL_CONTRACTS } from "../src/contracts/electronToolContracts.js"; +import { JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS } from "../src/contracts/javascriptRuntimeObservationToolContracts.js"; import { APPLICATION_TOOL_CONTRACTS } from "../src/contracts/applicationToolContracts.js"; import { SUPPORTED_CLIENT_DEFINITIONS } from "../src/application/SupportedClients.js"; import { PRODUCT_IDENTITY } from "../src/identity.js"; @@ -31,8 +32,9 @@ const expectedToolCounts = [ NATIVE_TOOL_CONTRACTS.length, ARTIFACT_TOOL_CONTRACTS.length, MANAGED_TOOL_CONTRACTS.length + MANAGED_WORKFLOW_TOOL_CONTRACTS.length, - BROWSER_TOOL_CONTRACTS.length, + BROWSER_PROVIDER_TOOL_CONTRACTS.length, ELECTRON_TOOL_CONTRACTS.length, + JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS.length, APPLICATION_TOOL_CONTRACTS.length, SESSION_TOOL_CONTRACTS.length, ] as const; diff --git a/tests/reconstructionObligationLedger.test.ts b/tests/reconstructionObligationLedger.test.ts new file mode 100644 index 000000000..e1339d0f9 --- /dev/null +++ b/tests/reconstructionObligationLedger.test.ts @@ -0,0 +1,587 @@ +import { describe, expect, it } from "vitest"; + +import { PROCESS_PROVIDER } from "../src/application/ProcessEvidence.js"; +import { + buildReconstructionObligationLedgerEvidenceValidated, + resolveReconstructionObligationLedgerRequest, +} from "../src/application/ReconstructionObligationLedgerService.js"; +import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../src/contracts/processCaptureExample.js"; +import { createEvidence, type Evidence } from "../src/domain/evidence.js"; +import { createEvidenceBundle } from "../src/domain/evidenceBundle.js"; +import { jsonValueSchema } from "../src/domain/jsonValue.js"; +import { processCaptureSchema } from "../src/domain/processCapture.js"; +import { + reconstructionObligationLedgerPageSchema, + serializeReconstructionObligationLedger, + type ReconstructionObligationLedgerInput, + type ReconstructionObligationLedgerPage, + type ReviewedReconstructionObligation, +} from "../src/domain/reconstructionObligationLedgerSchemas.js"; + +const proofEvidence = (id: string) => + createEvidence( + undefined, + { id: `fixture-${id}`, name: "Fixture verifier", version: "1" }, + { + predicateType: "rea.fixture-verification/v1", + operation: "run_fixture_verifier", + parameters: { id }, + result: { passed: true }, + confidence: "observed", + authority: "controlled-replay", + }, + ); + +const processEvidence = () => + createEvidence(undefined, PROCESS_PROVIDER, { + predicateType: "rea.process-capture/v4", + operation: "capture_process_scenario", + parameters: {}, + result: jsonValueSchema.parse( + processCaptureSchema.parse(EMPTY_PROCESS_CAPTURE_EXAMPLE), + ), + confidence: "observed", + authority: "controlled-replay", + }); + +const boundProofEvidence = ( + id: string, + result: { + readonly obligation_ids: readonly string[]; + readonly fixture_ids: readonly string[]; + readonly case_kinds: readonly string[]; + readonly verifier_ids: readonly string[]; + readonly claim_ids: readonly string[]; + }, + authority: "shipped-artifact" | "controlled-replay" = "controlled-replay", +) => + createEvidence( + undefined, + { id: `proof-${id}`, name: "Reconstruction proof", version: "1" }, + { + predicateType: "rea.reconstruction-proof/v1", + operation: "verify_reconstruction_obligations", + parameters: {}, + result: { + passed: true, + obligation_ids: [...result.obligation_ids], + fixture_ids: [...result.fixture_ids], + case_kinds: [...result.case_kinds], + verifier_ids: [...result.verifier_ids], + claim_ids: [...result.claim_ids], + }, + confidence: "observed", + authority, + }, + ); + +const request = ( + records: readonly Evidence[], + overrides: Partial = {}, +): ReconstructionObligationLedgerInput => ({ + evidence_bundle: createEvidenceBundle(records), + reviewed_obligations: [], + manifest: { schema_version: 1, bindings: [], contradictions: [] }, + limits: { max_obligations: 100 }, + page: { offset: 0, limit: 50 }, + ...overrides, +}); + +const build = ( + input: ReconstructionObligationLedgerInput, +): ReconstructionObligationLedgerPage => { + const parsed = resolveReconstructionObligationLedgerRequest(input); + if (!parsed.ok) throw parsed.error; + const result = buildReconstructionObligationLedgerEvidenceValidated( + parsed.value, + ); + if (!result.ok) throw result.error; + return reconstructionObligationLedgerPageSchema.parse( + result.value.normalized_result, + ); +}; + +const completeLedger = ( + page: ReconstructionObligationLedgerPage, + obligations: ReconstructionObligationLedgerPage["obligations"], +) => { + const { page: pagination, ...ledger } = page; + if (pagination.total !== obligations.length) + throw new TypeError("Incomplete obligation page set"); + return { ...ledger, obligations }; +}; + +const originalCases = ( + obligation: ReconstructionObligationLedgerPage["obligations"][number], + evidenceId: string, +) => + obligation.required_case_kinds.map((caseKind) => ({ + kind: caseKind, + evidence_id: evidenceId, + location: `/normalized_result/cases/${caseKind}`, + })); + +describe("reconstruction obligation ledger", () => { + it("emits deterministic closure identity and paging", () => { + const evidence = proofEvidence("review"); + const reviewed = [ + reviewedObligation("obl.review.one", evidence.evidence_id), + reviewedObligation("obl.review.two", evidence.evidence_id), + ]; + const first = build( + request([evidence], { + reviewed_obligations: reviewed, + page: { offset: 0, limit: 1 }, + }), + ); + const second = build( + request([evidence], { + reviewed_obligations: reviewed, + page: { offset: 1, limit: 1 }, + }), + ); + + expect(first.page).toEqual({ + offset: 0, + limit: 1, + total: 2, + returned: 1, + next_offset: 1, + }); + expect(second.page.next_offset).toBeNull(); + expect(second.ledger_id).toBe(first.ledger_id); + expect(second.closure_digest).toBe(first.closure_digest); + expect(first.obligations[0]?.source_state).toBe("reviewed"); + expect(first.status).toBe("open"); + expect( + serializeReconstructionObligationLedger( + completeLedger(first, [...first.obligations, ...second.obligations]), + ), + ).toBe( + serializeReconstructionObligationLedger( + completeLedger(second, [...first.obligations, ...second.obligations]), + ), + ); + }); + + it("does not let green unit evidence close a packaged-process obligation", () => { + const capture = processEvidence(); + const proof = proofEvidence("green-unit"); + const initial = build(request([capture, proof])); + const obligation = initial.obligations[0]; + if (obligation === undefined) + throw new Error("Expected process obligation"); + const unitBinding = { + obligation_id: obligation.obligation_id, + owner: { + module_path: "src/processOwner.ts", + symbol: "runProcess", + owner_sha256: "1".repeat(64), + }, + parser_type: null, + original_cases: originalCases(obligation, capture.evidence_id), + fixtures: obligation.required_case_kinds.map((caseKind) => ({ + fixture_id: `fixture.${caseKind}`, + case_kind: caseKind, + authority: "unit" as const, + evidence_ids: [proof.evidence_id], + })), + verifier: { + verifier_id: "verifier.unit", + claim_id: "claim.process", + command: "npm test -- processOwner", + authority: "unit" as const, + status: "pass" as const, + result_evidence_id: proof.evidence_id, + enumerated_obligation_ids: [obligation.obligation_id], + nondeterminism: { + mode: "total-order" as const, + specification: "Exact unit-call order.", + }, + }, + }; + + const result = build( + request([capture, proof], { + manifest: { + schema_version: 1, + bindings: [unitBinding], + contradictions: [], + }, + }), + ); + const evaluated = result.obligations[0]; + expect(evaluated?.status).toBe("implemented"); + expect(evaluated?.diagnostics.map(({ code }) => code)).toEqual( + expect.arrayContaining([ + "weak-fixture-authority", + "weak-verifier-authority", + ]), + ); + expect(result.status).toBe("open"); + }); + + it("keeps unobserved original cases open", () => { + const capture = processEvidence(); + const proof = proofEvidence("packaged-original-gap"); + const initial = build(request([capture, proof])); + const obligation = initial.obligations[0]; + if (obligation === undefined) + throw new Error("Expected process obligation"); + const result = build( + request([capture, proof], { + manifest: { + schema_version: 1, + bindings: [ + { + obligation_id: obligation.obligation_id, + owner: { + module_path: "src/processOwner.ts", + symbol: "runProcess", + owner_sha256: "4".repeat(64), + }, + parser_type: null, + original_cases: obligation.observed_cases, + fixtures: obligation.required_case_kinds.map((caseKind) => ({ + fixture_id: `packaged.${caseKind}`, + case_kind: caseKind, + authority: "packaged-process", + evidence_ids: [proof.evidence_id], + })), + verifier: { + verifier_id: "verifier.packaged", + claim_id: "claim.process", + command: "npm run verify:package -- process", + authority: "packaged-process", + status: "pass", + result_evidence_id: proof.evidence_id, + enumerated_obligation_ids: [obligation.obligation_id], + nondeterminism: { + mode: "partial-order", + specification: + "Startup precedes teardown; worker completion is unordered.", + }, + }, + }, + ], + contradictions: [], + }, + }), + ); + + expect(result.obligations[0]?.status).toBe("implemented"); + expect(result.obligations[0]?.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "missing-original-case", + detail: expect.stringContaining("negative"), + }), + expect.objectContaining({ + code: "missing-original-case", + detail: expect.stringContaining("cancellation"), + }), + ]), + ); + expect(result.status).toBe("open"); + }); + + it("closes only when every required case and verifier has comparable authority", () => { + const capture = processEvidence(); + const initial = build(request([capture])); + const obligation = initial.obligations[0]; + if (obligation === undefined) + throw new Error("Expected process obligation"); + const fixtureIds = obligation.required_case_kinds.map( + (caseKind) => `packaged.${caseKind}`, + ); + const proof = boundProofEvidence("packaged", { + obligation_ids: [obligation.obligation_id], + fixture_ids: fixtureIds, + case_kinds: obligation.required_case_kinds, + verifier_ids: ["verifier.packaged"], + claim_ids: ["claim.process"], + }); + const result = build( + request([capture, proof], { + manifest: { + schema_version: 1, + bindings: [ + { + obligation_id: obligation.obligation_id, + owner: { + module_path: "src/processOwner.ts", + symbol: "runProcess", + owner_sha256: "2".repeat(64), + }, + parser_type: null, + original_cases: originalCases(obligation, capture.evidence_id), + fixtures: obligation.required_case_kinds.map((caseKind) => ({ + fixture_id: `packaged.${caseKind}`, + case_kind: caseKind, + authority: "packaged-process", + evidence_ids: [proof.evidence_id], + })), + verifier: { + verifier_id: "verifier.packaged", + claim_id: "claim.process", + command: "npm run verify:package -- process", + authority: "packaged-process", + status: "pass", + result_evidence_id: proof.evidence_id, + enumerated_obligation_ids: [obligation.obligation_id], + nondeterminism: { + mode: "partial-order", + specification: + "Startup precedes teardown; worker completion is unordered.", + }, + }, + }, + ], + contradictions: [], + }, + }), + ); + + expect(result.obligations[0]).toMatchObject({ + status: "verified", + diagnostics: [], + }); + expect(result.status).toBe("ready"); + expect(result.summary.required_open).toBe(0); + }); + + it("does not authenticate packaged-process proof with generic replay evidence", () => { + const capture = processEvidence(); + const proof = proofEvidence("generic-packaged"); + const initial = build(request([capture, proof])); + const obligation = initial.obligations[0]; + if (obligation === undefined) + throw new Error("Expected process obligation"); + const result = build( + request([capture, proof], { + manifest: { + schema_version: 1, + bindings: [ + { + obligation_id: obligation.obligation_id, + owner: { + module_path: "src/processOwner.ts", + symbol: "runProcess", + owner_sha256: "5".repeat(64), + }, + parser_type: null, + original_cases: originalCases(obligation, capture.evidence_id), + fixtures: obligation.required_case_kinds.map((caseKind) => ({ + fixture_id: `packaged.${caseKind}`, + case_kind: caseKind, + authority: "packaged-process", + evidence_ids: [proof.evidence_id], + })), + verifier: { + verifier_id: "verifier.packaged", + claim_id: "claim.process", + command: "npm run verify:package -- process", + authority: "packaged-process", + status: "pass", + result_evidence_id: proof.evidence_id, + enumerated_obligation_ids: [obligation.obligation_id], + nondeterminism: { + mode: "partial-order", + specification: "Fixture scheduling is partially ordered.", + }, + }, + }, + ], + contradictions: [], + }, + }), + ); + + expect(result.obligations[0]?.diagnostics.map(({ code }) => code)).toEqual( + expect.arrayContaining([ + "weak-fixture-authority", + "weak-verifier-authority", + ]), + ); + }); + + it("propagates an open dependency through the full obligation chain", () => { + const ids = ["obl.chain.a", "obl.chain.b", "obl.chain.c"] as const; + const evidence = boundProofEvidence( + "fixture-chain", + { + obligation_ids: ids, + fixture_ids: ids.map((id) => `fixture.${id}`), + case_kinds: ["positive"], + verifier_ids: ids.map((id) => `verifier.${id}`), + claim_ids: ids.map((id) => `claim.${id}`), + }, + "shipped-artifact", + ); + const reviewed = ids.map((id, index) => ({ + ...reviewedObligation(id, evidence.evidence_id), + dependency_obligation_ids: + index === 0 ? [ids[1]] : index === 1 ? [ids[2]] : ["obl.chain.missing"], + })); + const bindings = reviewed.map((obligation) => ({ + obligation_id: obligation.obligation_id, + owner: { + module_path: `src/${obligation.obligation_id}.ts`, + symbol: "verify", + owner_sha256: "6".repeat(64), + }, + parser_type: null, + original_cases: [ + { + kind: "positive" as const, + evidence_id: evidence.evidence_id, + location: "/fixture/positive", + }, + ], + fixtures: [ + { + fixture_id: `fixture.${obligation.obligation_id}`, + case_kind: "positive" as const, + authority: "unit" as const, + evidence_ids: [evidence.evidence_id], + }, + ], + verifier: { + verifier_id: `verifier.${obligation.obligation_id}`, + claim_id: `claim.${obligation.obligation_id}`, + command: "npm test -- fixture-chain", + authority: "unit" as const, + status: "pass" as const, + result_evidence_id: evidence.evidence_id, + enumerated_obligation_ids: [obligation.obligation_id], + nondeterminism: { + mode: "total-order" as const, + specification: "Fixture execution is ordered.", + }, + }, + })); + const result = build( + request([evidence], { + reviewed_obligations: reviewed, + manifest: { schema_version: 1, bindings, contradictions: [] }, + }), + ); + + expect(result.obligations.map(({ status }) => status)).toEqual([ + "blocked", + "blocked", + "blocked", + ]); + expect( + result.obligations.map(({ diagnostics }) => + diagnostics.map(({ code }) => code), + ), + ).toEqual([ + ["dependency-open"], + ["dependency-open"], + ["dependency-missing"], + ]); + }); + + it("fails closed on parser gaps, ambiguous ownership, and contradiction", () => { + const original = createEvidence( + undefined, + { id: "external", name: "External protocol observer", version: "1" }, + { + predicateType: "rea.protocol-observation/v1", + operation: "observe_protocol", + parameters: {}, + result: { status: 200 }, + confidence: "observed", + authority: "external-service", + }, + ); + const proof = proofEvidence("protocol"); + const reviewed: ReviewedReconstructionObligation = { + ...reviewedObligation("obl.protocol", original.evidence_id), + application_layer: "protocol" as const, + family: "json-rpc", + required_original_authority: "external" as const, + required_fixture_authority: "protocol" as const, + required_verifier_authority: "protocol" as const, + requires_parser_type: true, + required_case_kinds: ["positive", "negative", "malformed"], + }; + const binding = { + obligation_id: reviewed.obligation_id, + owner: { + module_path: "src/protocol.ts", + symbol: "handleRequest", + owner_sha256: "3".repeat(64), + }, + parser_type: null, + original_cases: reviewed.required_case_kinds.map((caseKind) => ({ + kind: caseKind, + evidence_id: original.evidence_id, + location: `/protocol/${caseKind}`, + })), + fixtures: [ + { + fixture_id: "fixture.positive", + case_kind: "positive" as const, + authority: "unit" as const, + evidence_ids: [proof.evidence_id], + }, + ], + verifier: null, + }; + const result = build( + request([original, proof], { + reviewed_obligations: [reviewed], + manifest: { + schema_version: 1, + bindings: [binding, binding], + contradictions: [ + { + obligation_id: reviewed.obligation_id, + evidence_ids: [original.evidence_id, proof.evidence_id], + }, + ], + }, + }), + ); + + expect(result.status).toBe("failed"); + expect(result.obligations[0]?.status).toBe("contradicted"); + expect(result.obligations[0]?.diagnostics.map(({ code }) => code)).toEqual( + expect.arrayContaining(["ambiguous-owner", "contradiction"]), + ); + expect(result.reports.contradicted_obligation_ids).toEqual([ + reviewed.obligation_id, + ]); + }); +}); + +const reviewedObligation = ( + obligationId: string, + evidenceId: string, +): ReviewedReconstructionObligation => ({ + obligation_id: obligationId, + obligation_version: 1, + title: `Reviewed obligation ${obligationId}`, + application_layer: "other", + family: "reviewed", + target: { + artifact_sha256: null, + application_node_id: null, + semantic_node_id: null, + location: `/reviewed/${obligationId}`, + }, + required: true, + required_case_kinds: ["positive"], + required_original_authority: "static", + required_fixture_authority: "unit", + required_verifier_authority: "unit", + requires_parser_type: false, + dependency_obligation_ids: [], + residual_unknown_ids: [], + unavailable_authority: [], + required_next_evidence: ["Bind an owner and verifier."], + disposition: "active", + review_evidence_ids: [evidenceId], +}); diff --git a/tests/reconstructionObligationLedgerCli.test.ts b/tests/reconstructionObligationLedgerCli.test.ts new file mode 100644 index 000000000..e4198d537 --- /dev/null +++ b/tests/reconstructionObligationLedgerCli.test.ts @@ -0,0 +1,47 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +import { describe, expect, it } from "vitest"; + +import { createEvidenceBundle } from "../src/domain/evidenceBundle.js"; + +const execute = promisify(execFile); + +describe("reconstruction obligation ledger CLI parity", () => { + it("builds the same Evidence-backed empty ledger through the CLI", async () => { + const input = { + evidence_bundle: createEvidenceBundle([]), + reviewed_obligations: [], + manifest: { + schema_version: 1, + bindings: [], + contradictions: [], + }, + limits: { max_obligations: 100 }, + page: { offset: 0, limit: 50 }, + }; + const { stdout } = await execute( + process.execPath, + [ + "scripts/rea.mjs", + "build-reconstruction-obligation-ledger", + JSON.stringify(input), + "--json", + ], + { + cwd: process.cwd(), + env: process.env, + maxBuffer: 16 * 1_024 * 1_024, + }, + ); + expect(JSON.parse(stdout)).toMatchObject({ + operation: "build_reconstruction_obligation_ledger", + predicate_type: "rea.reconstruction-obligation-ledger/v1", + normalized_result: { + schema: "ReconstructionObligationLedger", + status: "unknown", + summary: { total: 0 }, + }, + }); + }, 20_000); +}); diff --git a/tests/reconstructionObligationLedgerMcp.test.ts b/tests/reconstructionObligationLedgerMcp.test.ts new file mode 100644 index 000000000..bab69c6fb --- /dev/null +++ b/tests/reconstructionObligationLedgerMcp.test.ts @@ -0,0 +1,73 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { describe, expect, it } from "vitest"; + +import { BinarySession } from "../src/application/BinarySession.js"; +import { createEvidenceBundle } from "../src/domain/evidenceBundle.js"; +import { createServer } from "../src/server/createServer.js"; +import { observed } from "./fixtures/analysisExecution.js"; + +describe("reconstruction obligation ledger MCP parity", () => { + it("builds an Evidence-backed page and exposes the retained resource", async () => { + const session = new BinarySession(() => ({ + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(), + })); + const server = createServer(session, session); + const client = new Client({ name: "obligation-ledger-test", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const result = await client.callTool({ + name: "build_reconstruction_obligation_ledger", + arguments: { + evidence_bundle: createEvidenceBundle([]), + reviewed_obligations: [], + manifest: { + schema_version: 1, + bindings: [], + contradictions: [], + }, + limits: { max_obligations: 100 }, + page: { offset: 0, limit: 50 }, + }, + }); + expect(result.isError).not.toBe(true); + expect(result.structuredContent).toMatchObject({ + evidence_id: expect.stringMatching(/^ev_[a-f0-9]{64}$/u), + evidence_uri: expect.stringMatching( + /^rea:\/\/evidence\/ev_[a-f0-9]{64}$/u, + ), + result: { + schema: "ReconstructionObligationLedger", + status: "unknown", + summary: { total: 0 }, + }, + }); + const evidence = result.structuredContent; + if ( + evidence === undefined || + evidence === null || + typeof evidence !== "object" || + !("evidence_id" in evidence) || + typeof evidence.evidence_id !== "string" + ) + throw new TypeError("Missing obligation ledger Evidence ID"); + const resource = await client.readResource({ + uri: `rea://evidence/${evidence.evidence_id}/reconstruction-obligation-ledger`, + }); + expect(resource.contents[0]).toEqual( + expect.objectContaining({ + text: expect.stringContaining( + '"schema": "ReconstructionObligationLedger"', + ), + }), + ); + } finally { + await client.close(); + await server.close(); + await session.close(); + } + }); +}); diff --git a/tests/reconstructionReadiness.test.ts b/tests/reconstructionReadiness.test.ts new file mode 100644 index 000000000..ad2b2c7ef --- /dev/null +++ b/tests/reconstructionReadiness.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from "vitest"; + +import { RECONSTRUCTION_READINESS_EXAMPLE } from "../src/contracts/reconstructionReadinessExample.js"; +import { createReconstructionReadinessReport } from "../src/domain/reconstructionReadiness.js"; +import type { ReconstructionReadinessInput } from "../src/domain/reconstructionReadinessSchemas.js"; + +const input = ( + overrides: Partial = {}, +): ReconstructionReadinessInput => ({ + ...structuredClone(RECONSTRUCTION_READINESS_EXAMPLE), + ...overrides, +}); + +describe("reconstruction readiness report", () => { + it("passes the complete public-contract journey deterministically", () => { + const first = createReconstructionReadinessReport(input()); + const second = createReconstructionReadinessReport(input()); + + expect(first.status).toBe("pass"); + expect(first.summary).toMatchObject({ + required_stages: 9, + passed_required_stages: 9, + findings: 0, + }); + expect(first.report_id).toBe(second.report_id); + expect(first.report_digest).toBe(second.report_digest); + expect(first.metrics).toMatchObject({ + first_valid_workflow_selection_rate: 1, + incompatible_provider_invocations: 0, + false_equivalence_count: 0, + unowned_obligation_count: 0, + cleanup_leak_count: 0, + nondeterministic_replay_count: 0, + }); + }); + + it("rejects equivalence over truncated concurrent evidence", () => { + const value = input(); + const comparison = value.comparisons[0]; + if (comparison === undefined) throw new TypeError("Missing comparison"); + value.comparisons[0] = { + ...comparison, + schedule_semantics: "total-order", + truncated: true, + }; + const report = createReconstructionReadinessReport(value); + + expect(report.status).toBe("fail"); + expect(report.findings.map(({ code }) => code)).toEqual( + expect.arrayContaining([ + "false-equivalence", + "unsafe-concurrency-semantics", + ]), + ); + expect(report.metrics.false_equivalence_count).toBe(1); + }); + + it("keeps green stages unknown while one reconstruction obligation is open", () => { + const value = input(); + value.obligation_ledger = { + ...value.obligation_ledger, + status: "open", + summary: { + ...value.obligation_ledger.summary, + verified: 0, + required_open: 1, + }, + }; + const report = createReconstructionReadinessReport(value); + + expect(report.status).toBe("unknown"); + expect(report.stages).toContainEqual( + expect.objectContaining({ + stage_id: "verify-reconstruction-closure", + status: "unknown", + }), + ); + expect(report.findings).toContainEqual( + expect.objectContaining({ code: "reconstruction-closure-open" }), + ); + }); + + it("fails typed operation outcomes that ordinary automation sees as success", () => { + const value = input(); + const cliOutcome = value.operation_outcomes[2]; + const mcpOutcome = value.operation_outcomes[3]; + if (cliOutcome === undefined || mcpOutcome === undefined) + throw new TypeError("Missing operation outcome pair"); + value.operation_outcomes[2] = { + ...cliOutcome, + cli_exit_code: 0, + }; + value.operation_outcomes[3] = { + ...mcpOutcome, + mcp_status: "success", + }; + const report = createReconstructionReadinessReport(value); + + expect(report.status).toBe("fail"); + expect(report.findings.map(({ code }) => code)).toEqual( + expect.arrayContaining(["cli-status-mismatch", "mcp-status-mismatch"]), + ); + }); + + it("detects tampering against the exported source digest", () => { + const baseline = createReconstructionReadinessReport(input()); + const replay = input({ + replay: { + ...RECONSTRUCTION_READINESS_EXAMPLE.replay, + expected_source_digest: baseline.source_digest, + }, + }); + expect(createReconstructionReadinessReport(replay).source_digest).toBe( + baseline.source_digest, + ); + replay.identity.cli_version = "tampered"; + const tampered = createReconstructionReadinessReport(replay); + + expect(tampered.status).toBe("fail"); + expect(tampered.findings).toContainEqual( + expect.objectContaining({ code: "replay-source-digest-mismatch" }), + ); + }); + + it("fails a reconstruction candidate that delegates to the authority", () => { + const value = input(); + const check = value.delegation_checks[0]; + if (check === undefined) throw new TypeError("Missing delegation check"); + value.delegation_checks[0] = { + ...check, + delegates_to_authority: true, + }; + const report = createReconstructionReadinessReport(value); + + expect(report.status).toBe("fail"); + expect(report.findings).toContainEqual( + expect.objectContaining({ code: "candidate-delegates-to-authority" }), + ); + }); + + it("preserves an exact unsupported stage without collapsing it to pass", () => { + const value = input(); + const stage = value.stages.find( + ({ stage_id: stageId }) => stageId === "reactive-scenarios", + ); + const check = stage?.checks[0]; + if (stage === undefined || check === undefined) + throw new TypeError("Missing reactive stage"); + stage.status = "unsupported"; + check.status = "unsupported"; + stage.capability_issue = "electron-runtime-unavailable"; + stage.next_action = "Run on a host with the approved Electron runtime."; + const report = createReconstructionReadinessReport(value); + + expect(report.status).toBe("unsupported"); + expect(report.stages).toContainEqual( + expect.objectContaining({ + stage_id: "reactive-scenarios", + status: "unsupported", + }), + ); + }); +}); diff --git a/tests/reconstructionReadinessCli.test.ts b/tests/reconstructionReadinessCli.test.ts new file mode 100644 index 000000000..665ad1a22 --- /dev/null +++ b/tests/reconstructionReadinessCli.test.ts @@ -0,0 +1,36 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +import { describe, expect, it } from "vitest"; + +import { RECONSTRUCTION_READINESS_EXAMPLE } from "../src/contracts/reconstructionReadinessExample.js"; + +const execute = promisify(execFile); + +describe("reconstruction readiness CLI parity", () => { + it("emits the same passing report through the public CLI", async () => { + const { stdout } = await execute( + process.execPath, + [ + "scripts/rea.mjs", + "evaluate-reconstruction-readiness", + JSON.stringify(RECONSTRUCTION_READINESS_EXAMPLE), + "--json", + ], + { + cwd: process.cwd(), + env: process.env, + maxBuffer: 32 * 1_024 * 1_024, + }, + ); + expect(JSON.parse(stdout)).toMatchObject({ + operation: "evaluate_reconstruction_readiness", + predicate_type: "rea.reconstruction-readiness-report/v1", + normalized_result: { + schema: "ReconstructionReadinessReport", + status: "pass", + summary: { passed_required_stages: 9 }, + }, + }); + }, 20_000); +}); diff --git a/tests/reconstructionReadinessMcp.test.ts b/tests/reconstructionReadinessMcp.test.ts new file mode 100644 index 000000000..4f5007711 --- /dev/null +++ b/tests/reconstructionReadinessMcp.test.ts @@ -0,0 +1,65 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { describe, expect, it } from "vitest"; + +import { BinarySession } from "../src/application/BinarySession.js"; +import { RECONSTRUCTION_READINESS_EXAMPLE } from "../src/contracts/reconstructionReadinessExample.js"; +import { createServer } from "../src/server/createServer.js"; +import { observed } from "./fixtures/analysisExecution.js"; + +describe("reconstruction readiness MCP parity", () => { + it("returns a passing compact result and retains the full report resource", async () => { + const session = new BinarySession(() => ({ + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(), + })); + const server = createServer(session, session); + const client = new Client({ name: "readiness-test", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const result = await client.callTool({ + name: "evaluate_reconstruction_readiness", + arguments: RECONSTRUCTION_READINESS_EXAMPLE, + }); + expect(result.isError).not.toBe(true); + expect(result.structuredContent).toMatchObject({ + evidence_id: expect.stringMatching(/^ev_[a-f0-9]{64}$/u), + result: { + schema: "ReconstructionReadinessReport", + status: "pass", + summary: { passed_required_stages: 9 }, + report_resource_uri: expect.stringMatching( + /^rea:\/\/evidence\/ev_[a-f0-9]{64}\/reconstruction-readiness-report$/u, + ), + }, + }); + expect(result.structuredContent).not.toHaveProperty("result.snapshot"); + expect(result.structuredContent).not.toHaveProperty("result.stages"); + const content = result.structuredContent; + if ( + content === undefined || + content === null || + typeof content !== "object" || + !("evidence_id" in content) || + typeof content.evidence_id !== "string" + ) + throw new TypeError("Missing readiness Evidence ID"); + const resource = await client.readResource({ + uri: `rea://evidence/${content.evidence_id}/reconstruction-readiness-report`, + }); + expect(resource.contents[0]).toEqual( + expect.objectContaining({ + text: expect.stringContaining( + '"schema": "ReconstructionReadinessReport"', + ), + }), + ); + } finally { + await client.close(); + await server.close(); + await session.close(); + } + }); +}); diff --git a/tests/runtime.test.ts b/tests/runtime.test.ts index e8f3ad75b..e5b2a09c4 100644 --- a/tests/runtime.test.ts +++ b/tests/runtime.test.ts @@ -3,6 +3,7 @@ import { fileURLToPath } from "node:url"; import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { describe, expect, it } from "vitest"; +import { z } from "zod"; import { CATALOG_IDENTITY } from "../src/catalogIdentity.js"; @@ -11,6 +12,29 @@ const fixturePath = fileURLToPath( new URL("./fixtures/fakeLauncher.mjs", import.meta.url), ); +const expectAvailableToolInventory = async (client: Client): Promise => { + const listed = await client.listTools(); + const status = await client.callTool({ + name: "binary_session", + arguments: { detail: "full" }, + }); + const availability = z + .object({ + result: z.object({ + tool_availability: z.array( + z.object({ name: z.string(), available: z.boolean() }), + ), + }), + }) + .parse(status.structuredContent).result.tool_availability; + expect(availability).toHaveLength(CATALOG_IDENTITY.counts.mcp_tools); + expect(new Set(listed.tools.map(({ name }) => name))).toEqual( + new Set( + availability.filter(({ available }) => available).map(({ name }) => name), + ), + ); +}; + describe("production stdio runtime", () => { it("starts the built entrypoint, lists the catalog, calls one, and shuts down", async () => { const transport = new StdioClientTransport({ @@ -33,8 +57,7 @@ describe("production stdio runtime", () => { try { await client.connect(transport); - const tools = await client.listTools(); - expect(tools.tools).toHaveLength(CATALOG_IDENTITY.counts.mcp_tools); + await expectAvailableToolInventory(client); const result = await client.callTool({ name: "current_document", arguments: {}, @@ -80,9 +103,7 @@ describe("production stdio runtime", () => { try { await client.connect(transport); - expect((await client.listTools()).tools).toHaveLength( - CATALOG_IDENTITY.counts.mcp_tools, - ); + await expectAvailableToolInventory(client); } finally { await client.close(); await transport.close(); diff --git a/tests/serverIdentity.test.ts b/tests/serverIdentity.test.ts index 6d44a1a41..3af948fe7 100644 --- a/tests/serverIdentity.test.ts +++ b/tests/serverIdentity.test.ts @@ -13,6 +13,48 @@ import { createServer } from "../src/server/createServer.js"; import { createServerIdentity } from "../src/serverIdentity.js"; import { observed } from "./fixtures/analysisExecution.js"; import { buildCapabilityInventory } from "../src/application/CapabilityInventory.js"; +import type { + AnalysisProvider, + CapabilityDescriptor, +} from "../src/application/AnalysisProvider.js"; + +const availabilityProvider = (): AnalysisProvider => { + const identity = { id: "fixture", name: "Fixture", version: "1" }; + const capability: CapabilityDescriptor = { + provider: identity, + operation: "current_address", + inputContractVersion: 1, + outputContractVersion: 1, + available: true, + reason: null, + pagination: "none", + exhaustive: true, + effects: { + mutatesArtifact: false, + launchesProcess: false, + mayShowUi: false, + mayAccessNetwork: false, + mayWriteFilesystem: false, + changesPermissions: false, + requiresRoot: false, + }, + limits: { + maxResults: null, + maxPayloadBytes: null, + timeoutMs: null, + }, + limitations: [], + }; + return { + identity: () => identity, + capabilities: () => [capability], + createClient: () => ({ + health: () => Promise.resolve(), + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(), + }), + }; +}; describe("server and catalog identity", () => { it("derives package and SDK versions from canonical package metadata", async () => { @@ -26,14 +68,14 @@ describe("server and catalog identity", () => { }); expect(PRODUCT_IDENTITY.packageVersion).toBe(packageJson.version); expect(SDK_IDENTITY.server).toBe("2.0.0-beta.4"); - expect(CLI_COMMAND_NAMES).toHaveLength(59); - expect(new Set(CLI_COMMAND_NAMES).size).toBe(59); + expect(CLI_COMMAND_NAMES).toHaveLength(66); + expect(new Set(CLI_COMMAND_NAMES).size).toBe(66); expect(CATALOG_IDENTITY.counts).toEqual({ - cli_commands: 59, + cli_commands: 66, mcp_tools: TOOL_CONTRACTS.length, mcp_prompts: 6, mcp_resources: 2, - mcp_resource_templates: 9, + mcp_resource_templates: 11, }); expect(CATALOG_IDENTITY.digests.combined_sha256).toMatch(/^[a-f0-9]{64}$/u); }); @@ -132,13 +174,16 @@ describe("server and catalog identity", () => { }); it("exposes live identity, labeled inventories, availability, and list changes", async () => { - const session = new BinarySession(() => ({ - health: () => Promise.resolve(), - execute: () => Promise.resolve(observed(null)), - close: () => Promise.resolve(), - })); + const session = new BinarySession(availabilityProvider()); const server = createServer(session, session); - const client = new Client({ name: "identity-test", version: "9" }); + const client = new Client( + { name: "identity-test", version: "9" }, + { + capabilities: { + elicitation: { form: {} }, + }, + }, + ); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); let toolListChanges = 0; @@ -149,7 +194,7 @@ describe("server and catalog identity", () => { await server.connect(serverTransport); await client.connect(clientTransport); const instructions = client.getInstructions(); - expect(instructions?.length).toBeLessThanOrEqual(512); + expect(instructions?.length).toBeLessThanOrEqual(640); expect(instructions).toContain( "ASAR/JavaScript -> analyze_javascript_application", ); @@ -200,6 +245,12 @@ describe("server and catalog identity", () => { name: "capture_process_scenario", available: false, reason: "policy_disabled", + client_requirements: { + required: [], + optional: ["elicitation_form"], + missing_required: [], + missing_optional: [], + }, }), expect.objectContaining({ name: "inspect_web_page", @@ -216,7 +267,7 @@ describe("server and catalog identity", () => { }), ]), client_features: { - elicitation_form: false, + elicitation_form: true, elicitation_url: false, roots: false, sampling: false, @@ -260,6 +311,12 @@ describe("server and catalog identity", () => { next_cursor: expect.any(Number), has_more: true, }, + client_features: { + elicitation_form: true, + elicitation_url: false, + roots: false, + sampling: false, + }, }, }); for (const capabilityFamily of TOOL_KINDS) { @@ -284,8 +341,17 @@ describe("server and catalog identity", () => { }, }); } - await client.callTool({ name: "close_binary", arguments: {} }); + expect( + (await client.listTools()).tools.map(({ name }) => name), + ).not.toContain("current_address"); + await client.callTool({ + name: "open_binary", + arguments: { path: process.execPath }, + }); await expect.poll(() => toolListChanges).toBeGreaterThan(0); + expect( + (await client.listTools()).tools.map(({ name }) => name), + ).toContain("current_address"); } finally { await Promise.allSettled([ client.close(), @@ -293,5 +359,5 @@ describe("server and catalog identity", () => { session.close(), ]); } - }); + }, 10_000); }); diff --git a/tests/sessionMcp.test.ts b/tests/sessionMcp.test.ts index dfacaf2ec..8efb7c478 100644 --- a/tests/sessionMcp.test.ts +++ b/tests/sessionMcp.test.ts @@ -9,8 +9,11 @@ import { z } from "zod"; import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js"; import { BinarySession } from "../src/application/BinarySession.js"; -import { TOOL_CONTRACTS } from "../src/contracts/toolContracts.js"; -import type { AnalysisClient } from "../src/application/AnalysisProvider.js"; +import type { + AnalysisClient, + AnalysisProvider, + CapabilityDescriptor, +} from "../src/application/AnalysisProvider.js"; import { probeProcessCaptureCapability } from "../src/application/ProcessHarness.js"; import { observed as ok } from "./fixtures/analysisExecution.js"; import { createServer } from "../src/server/createServer.js"; @@ -47,7 +50,7 @@ describe("target-free MCP lifecycle", () => { const targetPath = join(directory, "mutable.hop"); await writeFile(targetPath, "first"); const closed: string[] = []; - const session = new BinarySession((target) => client(target.path, closed), { + const session = new BinarySession(provider(closed), { resolveAnalysisProfile: () => Promise.resolve( resultOk({ profile: SNAPSHOT_PROFILE, compatibility: {} }), @@ -106,7 +109,7 @@ describe("target-free MCP lifecycle", () => { await writeFile(first, "one"); await writeFile(second, "two"); const closed: string[] = []; - const session = new BinarySession((target) => client(target.path, closed), { + const session = new BinarySession(provider(closed), { resolveAnalysisProfile: () => Promise.resolve( resultOk({ profile: SNAPSHOT_PROFILE, compatibility: {} }), @@ -140,34 +143,23 @@ describe("target-free MCP lifecycle", () => { await server.connect(serverTransport); await mcp.connect(clientTransport); - const before = await mcp.callTool({ - name: "current_document", - arguments: {}, - }); - expect(before.isError).toBe(true); - expect(text(before)).toBe(JSON.stringify(before.structuredContent)); - expect((await mcp.listTools()).tools).toHaveLength(TOOL_CONTRACTS.length); - const deniedCapture = await mcp.callTool({ - name: "capture_process_scenario", - arguments: { - approved: true, - executable: "/bin/sh", - working_directory: "/tmp", - }, - }); - expect(deniedCapture.isError).toBe(true); - expect(structured(deniedCapture)).toMatchObject({ - error: { - category: "permission_required", - }, - }); - expect(text(deniedCapture)).toBe( - JSON.stringify(deniedCapture.structuredContent), - ); + const beforeNames = (await mcp.listTools()).tools.map(({ name }) => name); + expect(beforeNames).toContain("open_binary"); + expect(beforeNames).toContain("binary_session"); + expect(beforeNames).not.toContain("current_document"); + expect(beforeNames).not.toContain("capture_process_scenario"); expect( (await mcp.callTool({ name: "open_binary", arguments: { path: first } })) .isError, ).not.toBe(true); + expect( + structured( + await mcp.callTool({ + name: "binary_session", + arguments: { detail: "full" }, + }), + ), + ).toMatchObject({ result: { path: await realpath(first) } }); expect( text(await mcp.callTool({ name: "current_document", arguments: {} })), ).toContain("first.hop"); @@ -391,6 +383,40 @@ const client = (path: string, closed: string[]): AnalysisClient => ({ }, }); +const provider = (closed: string[]): AnalysisProvider => { + const identity = { id: "fixture", name: "Fixture", version: "1" }; + const capability: CapabilityDescriptor = { + provider: identity, + operation: "current_document", + inputContractVersion: 1, + outputContractVersion: 1, + available: true, + reason: null, + pagination: "none", + exhaustive: true, + effects: { + mutatesArtifact: false, + launchesProcess: false, + mayShowUi: false, + mayAccessNetwork: false, + mayWriteFilesystem: false, + changesPermissions: false, + requiresRoot: false, + }, + limits: { + maxResults: null, + maxPayloadBytes: null, + timeoutMs: null, + }, + limitations: [], + }; + return { + identity: () => identity, + capabilities: () => [capability], + createClient: (target) => client(target.path, closed), + }; +}; + const text = (result: CallToolResult): string => { const content = result.content.find((item) => item.type === "text"); if (content?.type !== "text") throw new Error("missing text result"); diff --git a/tests/sourceToBundleComparison.test.ts b/tests/sourceToBundleComparison.test.ts new file mode 100644 index 000000000..12840a26d --- /dev/null +++ b/tests/sourceToBundleComparison.test.ts @@ -0,0 +1,320 @@ +import { describe, expect, it } from "vitest"; + +import { + createJavaScriptApplicationGraph, + createJavaScriptApplicationNode, +} from "../src/domain/javascriptApplicationGraph.js"; +import { createHistoricalSourceGraph } from "../src/domain/referenceSourceGraph.js"; +import { compareSourceToBundle } from "../src/domain/sourceToBundleComparison.js"; +import { sourceToBundleComparisonResultSchema } from "../src/domain/sourceToBundleComparisonSchemas.js"; +import { artifactEvidence } from "./fixtures/javascriptApplicationGraph.js"; + +const HASH = { + artifact: "1".repeat(64), + unchanged: "2".repeat(64), + modified: "3".repeat(64), + removed: "4".repeat(64), + split: "5".repeat(64), + merged: "6".repeat(64), + duplicated: "7".repeat(64), +} as const; +const EVIDENCE_ID = `ev_${"a".repeat(64)}`; + +describe("historical source to bundle comparison", () => { + it("classifies exact, changed, absent, split, and merged mappings stably", () => { + const reference = historicalGraph("complete"); + const graph = applicationGraph("complete"); + const input = { + reference, + application: { + evidenceId: EVIDENCE_ID, + rootArtifactSha256: HASH.artifact, + graph, + }, + limits: limits(), + }; + + const first = compareSourceToBundle(input); + const second = compareSourceToBundle(input); + + expect(first).toEqual(second); + expect(() => + sourceToBundleComparisonResultSchema.parse(first), + ).not.toThrow(); + expect(first.summary).toEqual({ + unchanged: 1, + modified: 0, + removed: 1, + split: 1, + merged: 2, + duplicated: 1, + unknown: 1, + }); + expect(item(first, "src/unchanged.ts")).toMatchObject({ + status: "unchanged", + confidence: "exact", + candidates: [ + expect.objectContaining({ + signals: expect.arrayContaining([ + expect.objectContaining({ + kind: "exact-source-digest", + weight: 100, + }), + ]), + }), + ], + }); + expect(item(first, "src/modified.ts")).toMatchObject({ + status: "unknown", + confidence: "unknown", + candidates: [ + expect.objectContaining({ + signals: expect.arrayContaining([ + expect.objectContaining({ kind: "current-path-exact", weight: 60 }), + ]), + }), + ], + }); + expect(item(first, "src/removed.ts")).toMatchObject({ + status: "removed", + current_node_ids: [], + }); + expect(item(first, "src/split.ts")).toMatchObject({ + status: "split", + confidence: "medium", + current_node_ids: [expect.any(String), expect.any(String)], + }); + expect(item(first, "src/merged-a.ts").status).toBe("merged"); + expect(item(first, "src/merged-b.ts").status).toBe("merged"); + expect(item(first, "src/duplicated.ts")).toMatchObject({ + status: "duplicated", + confidence: "exact", + current_node_ids: [expect.any(String), expect.any(String)], + }); + expect(first.coverage.status).toBe("complete-within-inputs"); + }); + + it("keeps absence unknown when either inventory is incomplete", () => { + const reference = historicalGraph("partial", ["src/removed.ts"]); + const graph = applicationGraph("partial"); + const result = compareSourceToBundle({ + reference, + application: { + evidenceId: EVIDENCE_ID, + rootArtifactSha256: HASH.artifact, + graph, + }, + limits: limits(), + }); + + expect(result.summary).toMatchObject({ removed: 0, unknown: 1 }); + expect(result.items[0]).toMatchObject({ + source_path: "src/removed.ts", + status: "unknown", + confidence: "unknown", + }); + expect(result.coverage.status).toBe("partial"); + }); + + it("keeps exact mappings unknown when candidate output is truncated", () => { + const result = compareSourceToBundle({ + reference: historicalGraph("complete"), + application: { + evidenceId: EVIDENCE_ID, + rootArtifactSha256: HASH.artifact, + graph: applicationGraph("complete"), + }, + limits: { ...limits(), max_candidate_nodes: 1 }, + }); + + expect(item(result, "src/duplicated.ts").status).toBe("unknown"); + expect( + item(result, "src/duplicated.ts").omitted_candidates, + ).toBeGreaterThan(0); + }); + + it("reports the candidate frontier instead of deciding past a limit", () => { + const result = compareSourceToBundle({ + reference: historicalGraph("complete", ["src/split.ts"]), + application: { + evidenceId: EVIDENCE_ID, + rootArtifactSha256: HASH.artifact, + graph: applicationGraph("complete"), + }, + limits: { ...limits(), max_candidate_evaluations: 1 }, + }); + + expect(result.items[0]).toMatchObject({ + status: "unknown", + confidence: "unknown", + omitted_candidates: 1, + }); + expect(result.coverage).toMatchObject({ + status: "truncated", + candidate_evaluations: 1, + omitted_candidate_evaluations: 1, + }); + }); +}); + +const historicalGraph = ( + inventoryState: "complete" | "partial", + paths = [ + "src/duplicated.ts", + "src/merged-a.ts", + "src/merged-b.ts", + "src/modified.ts", + "src/removed.ts", + "src/split.ts", + "src/unchanged.ts", + ], +) => + createHistoricalSourceGraph({ + schema: "HistoricalSourceGraph/v1", + authority: "historical-reference", + root_alias: "$REFERENCE_ROOT", + inventory_state: inventoryState, + entries: [ + { + path: "src", + kind: "directory", + classifications: ["source"], + tree_state: inventoryState === "complete" ? "enumerated" : "partial", + limitations: + inventoryState === "complete" + ? [] + : ["Fixture inventory is partial."], + }, + ...paths.map((path) => ({ + path, + kind: "file" as const, + sha256: sourceDigest(path), + size: 10, + language: "TypeScript", + classifications: ["source"] as const, + content_state: "hashed" as const, + limitations: [], + })), + ], + relationships: [], + parse_failures: [], + exclusions: [], + languages: ["TypeScript"], + manifests: [], + vcs: { kind: "none", head: null, dirty: null }, + provenance: { + importer: "rea-test", + importer_version: "1", + caller: "source-to-bundle-test", + }, + limitations: + inventoryState === "complete" ? [] : ["Fixture inventory is partial."], + }); + +const applicationGraph = (coverage: "complete" | "partial") => { + const nodes = [ + moduleNode("duplicated-a", { + path: "bundle-a.js", + source_sha256: HASH.duplicated, + }), + moduleNode("duplicated-b", { + path: "bundle-b.js", + source_sha256: HASH.duplicated, + }), + moduleNode("unchanged", { + path: "bundle.js", + source_sha256: HASH.unchanged, + }), + createJavaScriptApplicationNode({ + kind: "javascript-asset", + identity: { + strategy: "canonical-path", + stability: "artifact-version", + artifact_sha256: HASH.artifact, + path: "src/modified.ts", + }, + observations: [ + { + label: "src/modified.ts", + properties: { path: "src/modified.ts" }, + evidence: artifactEvidence(HASH.artifact, "src/modified.ts"), + }, + ], + }), + moduleNode("split-a", { path: "bundle-a/src/split.ts" }), + moduleNode("split-b", { path: "bundle-b/src/split.ts" }), + moduleNode("merged", { + path: "bundle.js", + source_sha256: HASH.merged, + }), + ]; + return createJavaScriptApplicationGraph({ + schema: "JavaScriptApplicationGraph", + schema_version: 1, + root_node_ids: nodes.map(({ node_id: nodeId }) => nodeId), + nodes, + edges: [], + coverage: + coverage === "complete" + ? { + status: "complete", + truncated: false, + omitted_count: 0, + limits: [], + } + : { + status: "partial", + truncated: true, + omitted_count: 1, + limits: [{ name: "fixture", value: 5, unit: "items" }], + }, + limitations: + coverage === "complete" ? [] : ["Fixture graph coverage is partial."], + }); +}; + +const moduleNode = (key: string, properties: Record) => + createJavaScriptApplicationNode({ + kind: "javascript-module", + identity: { + strategy: "artifact-local-key", + stability: "artifact-version", + artifact_sha256: HASH.artifact, + namespace: "test", + key, + }, + observations: [ + { + label: key, + properties, + evidence: artifactEvidence(HASH.artifact, "bundle.js"), + }, + ], + }); + +const sourceDigest = (path: string): string => { + if (path.includes("duplicated")) return HASH.duplicated; + if (path.includes("merged")) return HASH.merged; + if (path.includes("modified")) return HASH.modified; + if (path.includes("removed")) return HASH.removed; + if (path.includes("split")) return HASH.split; + return HASH.unchanged; +}; + +const limits = () => ({ + max_source_files: 100, + max_application_nodes: 100, + max_candidate_nodes: 100, + max_candidate_evaluations: 10_000, +}); + +const item = ( + result: ReturnType, + sourcePath: string, +) => { + const value = result.items.find( + ({ source_path: path }) => path === sourcePath, + ); + if (value === undefined) throw new TypeError(`Missing item: ${sourcePath}`); + return value; +}; diff --git a/tests/toolContracts.test.ts b/tests/toolContracts.test.ts index 9c1163009..a304d0bbf 100644 --- a/tests/toolContracts.test.ts +++ b/tests/toolContracts.test.ts @@ -16,10 +16,14 @@ import { APPLICATION_TOOL_CONTRACTS } from "../src/contracts/applicationToolCont describe("tool contract inventory", () => { it("publishes the canonical analysis and session tool inventory", () => { - expect(OFFICIAL_TOOL_CONTRACTS).toHaveLength(34); - expect(ENHANCED_TOOL_CONTRACTS).toHaveLength(10); + expect(OFFICIAL_TOOL_CONTRACTS).toHaveLength(36); + expect(ENHANCED_TOOL_CONTRACTS).toHaveLength(12); expect(NATIVE_TOOL_CONTRACTS).toHaveLength(5); - expect(ARTIFACT_TOOL_CONTRACTS).toHaveLength(2); + expect(ARTIFACT_TOOL_CONTRACTS.map(({ name }) => name)).toEqual([ + "inventory_artifact", + "inspect_artifact", + "extract_artifact", + ]); expect(MANAGED_TOOL_CONTRACTS.map(({ name }) => name)).toEqual([ "inspect_managed_artifact", "inspect_managed_members", @@ -52,10 +56,13 @@ describe("tool contract inventory", () => { "trace_application_feature", "trace_javascript_semantics", "compare_application_versions", + "compare_source_to_bundle", "compare_javascript_export_shapes", "run_controlled_replay", "prepare_node_characterization", "execute_node_characterization", + "build_reconstruction_obligation_ledger", + "evaluate_reconstruction_readiness", "commit_reconstruction_coverage", "query_reconstruction_coverage", ]); diff --git a/tests/webCaptureDiff.test.ts b/tests/webCaptureDiff.test.ts index 65a6df17f..37f7c9a48 100644 --- a/tests/webCaptureDiff.test.ts +++ b/tests/webCaptureDiff.test.ts @@ -5,6 +5,7 @@ import { inspectWebPageInputSchema } from "../src/domain/browserObservation.js"; import { compareWebCaptures, compareWebCapturesInputSchema, + webCaptureDiffSchema, } from "../src/domain/webCaptureDiff.js"; import { startFakeCdpBrowser, @@ -59,6 +60,17 @@ describe("web capture diff", () => { status: "unknown", total_changes: 0, }); + const { + accessibility: _accessibility, + storage: _storage, + ...legacy + } = result.dimensions; + const parsedLegacy = webCaptureDiffSchema.parse({ + ...result, + dimensions: legacy, + }); + expect(parsedLegacy.dimensions.accessibility.status).toBe("unknown"); + expect(parsedLegacy.dimensions.storage.status).toBe("unknown"); }); it("does not claim unchanged when a relevant section is incomplete", async () => { @@ -125,6 +137,193 @@ describe("web capture diff", () => { expect(result.dimensions.network.status).toBe("unknown"); expect(result.dimensions.network.total_changes).toBe(0); }); + + it("detects accessibility semantics and storage inventory changes", async () => { + const browser = await startFakeCdpBrowser({ extraCollections: true }); + browsers.push(browser); + const captured = await new CdpBrowserProvider().inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 0, + include_accessibility_text: true, + include_storage_keys: true, + include_storage_fingerprints: true, + }), + ); + if (!captured.ok) throw captured.error; + const compareAfter = (after: typeof captured.value) => + compareWebCaptures( + compareWebCapturesInputSchema.parse({ + before: { inspection: captured.value }, + after: { inspection: after }, + }), + ); + const roleChanged = structuredClone(captured.value); + const namedNode = roleChanged.accessibility.nodes[0]; + if (namedNode === undefined) + throw new Error("Expected accessibility nodes"); + namedNode.role = "menuitem"; + + const nameChanged = structuredClone(captured.value); + const renamedNode = nameChanged.accessibility.nodes[0]; + if (renamedNode === undefined) + throw new Error("Expected accessibility nodes"); + renamedNode.name = "Send report"; + + const stateChanged = structuredClone(captured.value); + const stateNode = stateChanged.accessibility.nodes[0]; + const disabledState = stateNode?.states.find( + ({ name }) => name === "disabled", + ); + if (disabledState === undefined) + throw new Error("Expected an accessibility state"); + disabledState.value = true; + + const hierarchyChanged = structuredClone(captured.value); + const childNode = hierarchyChanged.accessibility.nodes[1]; + if (childNode === undefined) + throw new Error("Expected an accessibility child"); + childNode.parent_id = null; + + const storageChanged = structuredClone(captured.value); + storageChanged.storage.local_storage_keys.push("new-key"); + + for (const after of [ + roleChanged, + nameChanged, + stateChanged, + hierarchyChanged, + ]) + expect(compareAfter(after).dimensions.accessibility).toMatchObject({ + status: "changed", + changes: [ + { + identity: "accessibility_tree", + change: "modified", + }, + ], + }); + expect(compareAfter(storageChanged).dimensions.storage).toMatchObject({ + status: "changed", + changes: [expect.objectContaining({ change: "added" })], + }); + }); + + it("uses complete redacted fingerprints but keeps incomplete evidence unknown", async () => { + const browser = await startFakeCdpBrowser({ extraCollections: true }); + browsers.push(browser); + const captured = await new CdpBrowserProvider().inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 0, + include_accessibility_text: true, + include_storage_keys: true, + include_storage_fingerprints: true, + }), + ); + if (!captured.ok) throw captured.error; + const identical = structuredClone(captured.value); + const identity = compareWebCaptures( + compareWebCapturesInputSchema.parse({ + before: { inspection: captured.value }, + after: { inspection: identical }, + }), + ); + expect(identity.dimensions.accessibility.status).toBe("unchanged"); + expect(identity.dimensions.storage).toMatchObject({ + status: "unchanged", + total_changes: 0, + }); + + const changedFingerprint = structuredClone(captured.value); + const fingerprint = changedFingerprint.storage.content_fingerprints.find( + ({ complete, value_sha256: valueSha256 }) => + complete && valueSha256 !== null, + ); + if (fingerprint === undefined || fingerprint.value_sha256 === null) + throw new Error("Expected a complete content fingerprint"); + fingerprint.value_sha256 = "f".repeat(64); + const storageChanged = compareWebCaptures( + compareWebCapturesInputSchema.parse({ + before: { inspection: captured.value }, + after: { inspection: changedFingerprint }, + }), + ); + expect(storageChanged.dimensions.storage).toMatchObject({ + status: "changed", + total_changes: 1, + changes: [expect.objectContaining({ change: "modified" })], + }); + + const incompleteStorage = structuredClone(captured.value); + incompleteStorage.storage.fingerprints_complete = false; + const storageUnknown = compareWebCaptures( + compareWebCapturesInputSchema.parse({ + before: { inspection: captured.value }, + after: { inspection: incompleteStorage }, + }), + ); + expect(storageUnknown.dimensions.storage).toMatchObject({ + status: "unknown", + total_changes: 0, + }); + + const evidenceQualityChanged = structuredClone(captured.value); + evidenceQualityChanged.storage.fingerprints_complete = false; + const incompleteFingerprint = + evidenceQualityChanged.storage.content_fingerprints[0]; + if (incompleteFingerprint === undefined) + throw new Error("Expected a content fingerprint"); + incompleteFingerprint.complete = false; + const evidenceOnly = compareWebCaptures( + compareWebCapturesInputSchema.parse({ + before: { inspection: captured.value }, + after: { inspection: evidenceQualityChanged }, + }), + ); + expect(evidenceOnly.dimensions.storage).toMatchObject({ + status: "unknown", + total_changes: 0, + }); + + const withoutText = structuredClone(captured.value); + withoutText.accessibility.text_capture.status = "not_approved"; + withoutText.accessibility.text_capture.excluded_fields = 1; + for (const node of withoutText.accessibility.nodes) { + node.name = null; + node.description = null; + } + const missingText = compareWebCaptures( + compareWebCapturesInputSchema.parse({ + before: { inspection: captured.value }, + after: { inspection: withoutText }, + }), + ); + expect(missingText.dimensions.accessibility).toMatchObject({ + status: "unknown", + total_changes: 0, + }); + + const truncated = structuredClone(captured.value); + truncated.accessibility.total_nodes += 1; + truncated.completeness.truncated_sections.push("accessibility"); + const incompleteTree = compareWebCaptures( + compareWebCapturesInputSchema.parse({ + before: { inspection: truncated }, + after: { inspection: structuredClone(truncated) }, + }), + ); + expect(incompleteTree.dimensions.accessibility).toMatchObject({ + status: "unknown", + total_changes: 0, + }); + }); }); const markSectionsComplete = (