diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index b532586016..b4abd478dd 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -28,7 +28,7 @@ intentionally not included. --> - Provider, bridge, target-format, or platform compatibility: - Evidence, artifact, provenance, or reconstruction contract: - Process execution, authorization, cleanup, or containment impact: -- Generated metadata (`docs/product-catalog.json`), package, or installation impact (the regen-generated bot pushes tracked generated files; never hand-edit them): +- Generated metadata (`docs/public/product-catalog.json`), package, or installation impact (CI generates and validates ignored catalogs and `skills/`; edit authored instructions in `skill-src/` and never commit build outputs): ## Evidence and regression coverage diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef5d671b4e..44930ad212 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -328,6 +328,8 @@ jobs: run: npm run test:focused -- tests/boundary/cli/nativeDemangleCli.test.ts tests/boundary/mcp/nativeDemangleMcp.test.ts - name: Process filesystem snapshot descriptor regressions run: npm run test:focused -- src/process/capture/FilesystemSnapshot.test.ts + - name: Native Darwin ownership and process cleanup regressions + run: npm run test:focused -- tests/boundary/process/darwinProcessRunTokenReader.test.ts tests/boundary/process/processCaptureLifecycle.test.ts tests/boundary/cli/processCli.test.ts tests/boundary/mcp/sessionMcp.test.ts reconstruction-readiness: name: Reconstruction readiness @@ -442,6 +444,11 @@ jobs: tests/boundary/filesystem/safeOutputTree.test.ts tests/boundary/providers/native/artifactProvider.archiveSafety.test.ts tests/boundary/filesystem/setupUnavailableProvider.test.ts + tests/boundary/filesystem/windowsNpxRegistration.test.ts + src/application/ClientRegistrationIdentity.test.ts + tests/boundary/filesystem/webScriptManifestPaths.test.ts + tests/acceptance/webModuleCli.test.ts + tests/acceptance/webSourceLocationCli.test.ts tests/boundary/cli/referenceSourceImportStatus.test.ts src/cliReferenceSourceImportStatus.test.ts tests/boundary/filesystem/asarInventoryFreshness.test.ts @@ -482,3 +489,13 @@ jobs: cache: npm - run: npm ci - run: npm run docs:check + - name: Generation leaves authored files unchanged + run: git diff --exit-code + - uses: actions/upload-artifact@v4 + with: + name: generated-docs + path: | + docs/public/product-catalog.json + docs/verification/managed-conformance-*.json + skills/reverse-engineer-anything + if-no-files-found: error diff --git a/.github/workflows/regen-generated.yml b/.github/workflows/regen-generated.yml deleted file mode 100644 index 0ce5e9c5ff..0000000000 --- a/.github/workflows/regen-generated.yml +++ /dev/null @@ -1,75 +0,0 @@ -name: Regenerate generated metadata - -on: - pull_request: - branches: [main] - paths: - - "src/**" - - "skills/**/SKILL.md" - - "scripts/generate-*.mjs" - - "scripts/check-doc-facts.mjs" - - "scripts/lib/*.mjs" - - "scripts/verify/managed/**" - - "scripts/fixtures/managed/**" - - "package.json" - - "package-lock.json" - -concurrency: - group: regen-${{ github.event.pull_request.number }} - cancel-in-progress: true - -env: - HUSKY: "0" - -permissions: - contents: write - -jobs: - regen: - name: Regenerate and push tracked generated files - # Same-repo PRs only: the default token cannot push to forks. - # The bot-actor guard stops looped runs triggered by our own push. - if: >- - github.actor != 'github-actions[bot]' && - github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.ref }} - - uses: actions/setup-node@v4 - with: - node-version-file: .nvmrc - cache: npm - - run: npm ci - - run: npm run docs:generate - - name: Commit regenerated files - shell: bash - env: - HEAD_REF: ${{ github.event.pull_request.head.ref }} - run: | - generated_paths=( - docs/product-catalog.json - docs/error-contract.schema.json - docs/verification/managed-conformance-manifest.json - docs/verification/managed-conformance-ledger.json - skills/reverse-engineer-anything/SKILL.md - src/generatedPackageMetadata.ts - ) - if git diff --quiet -- "${generated_paths[@]}"; then - echo "Generated metadata is already canonical." - exit 0 - fi - git diff --check - # Refuse to sweep unrelated working-tree changes into the bot commit. - unexpected="$(git diff --name-only | grep -vxF -f <(printf '%s\n' "${generated_paths[@]}") || true)" - if [[ -n "${unexpected}" ]]; then - echo "docs:generate left unexpected tracked changes:" >&2 - printf '%s\n' "${unexpected}" >&2 - exit 1 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -- "${generated_paths[@]}" - git commit -m "chore: regenerate generated metadata" - git push origin "HEAD:${HEAD_REF}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 04e385404a..e10fc62061 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -170,43 +170,9 @@ jobs: if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true' run: npm run docs:generate - - name: Commit canonical release catalog + - name: Validate generated release documentation if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true' - shell: bash - env: - # Use the same identity for the follow-up commit so it also triggers CI. - GH_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} - RELEASE_PR_JSON: ${{ steps.release.outputs.pr }} - run: | - release_pr_branch="$(node --input-type=module -e ' - const value = JSON.parse(process.env.RELEASE_PR_JSON ?? ""); - if ( - typeof value !== "object" || - value === null || - typeof value.headBranchName !== "string" || - value.headBranchName.length === 0 - ) { - process.exit(1); - } - process.stdout.write(value.headBranchName); - ')" - changed_paths="$(git diff --name-only)" - if [[ -z "${changed_paths}" ]]; then - echo "Release documentation is already canonical." - exit 0 - fi - if [[ "${changed_paths}" != "docs/product-catalog.json" ]]; then - echo "docs:generate changed unexpected tracked paths:" >&2 - printf '%s\n' "${changed_paths}" >&2 - exit 1 - fi - git diff --check - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -- docs/product-catalog.json - git commit -m "chore: normalize release product catalog" - gh auth setup-git - git push origin "HEAD:${release_pr_branch}" + run: npm run docs:check && git diff --exit-code publish: name: Publish to npm diff --git a/.gitignore b/.gitignore index aa778dec18..a362e63e81 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ test-results.xml .turbo/ .cache/ docs/.vitepress/cache/ +docs/.vitepress/dist/ # OS .DS_Store @@ -51,3 +52,10 @@ Thumbs.db # Build-generated MCP tool catalog (regenerated by `npm run build`; never commit) src/generatedMcpToolCatalog.ts + +# Derived catalogs, portable conformance projections, and packaged skill bundles +# (regenerated from this checkout by `npm run build:cached`; never commit) +/skills/ +/docs/public/product-catalog.json +/docs/verification/managed-conformance-manifest.json +/docs/verification/managed-conformance-ledger.json diff --git a/AGENTS.md b/AGENTS.md index c12152690e..f7f8d9ea16 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ REA is a layered ESM TypeScript application. Dependencies flow inward from pure - `src/application/` composes shared CLI/MCP workflows; `src/server/` translates MCP requests; `src/cli.ts` and `src/main.ts` are the CLI and MCP entry points. - `src/process/` owns shared process lifecycle primitives, not provider wire protocols. `bridge/` contains provider-side adapters. - `tests/` contains unit, composition, boundary, acceptance, and conformance tests. `scripts/verify-*` and capability directories under `scripts/verify/` contain real-toolchain checks. -- `docs/product-catalog.json` is generated. Update its source contracts and regenerate it; do not edit it directly. +- `docs/public/product-catalog.json`, `docs/verification/managed-conformance-*.json`, and `skills/` are ignored build outputs. Update source contracts and authored instructions in `skill-src/`, then run `npm run build:cached`; never commit derived catalog digests or portable conformance projections. - `src/generatedMcpToolCatalog.ts` is build-generated and gitignored. Never commit it; resolve any trace of it in merges by deleting it and running `npm run build:cached`. ## Build, Test, and Development Commands @@ -36,7 +36,7 @@ REA is a layered ESM TypeScript application. Dependencies flow inward from pure - `npm run check:changed`: run cached static checks and source tests affected since the branch merge base (default `origin/main`). - `npm run check:fast`: run cached typecheck and lint checks. - `npm run check:pr`: opt into the complete local deterministic gate and generated-document checks for broad changes; CI owns full coverage. Routine iterations need focused tests and relevant checks, not the whole gate each time. -- `npm run docs:check`: check committed generated documents; `npm run docs:generate` regenerates them. +- `npm run docs:check`: build and validate generated documents for the current checkout; `npm run docs:generate` regenerates them. CI retains ignored outputs as artifacts and does not push snapshot commits to feature branches. - For provider-dependent changes, see [docs/testing.md](docs/testing.md) and run the matching real-provider verification. - Keep each verification lane's prerequisites limited to the claim it checks. Use host-native fixtures for host/provider acceptance; put optional cross-target formats and their external toolchains in a separate lane. Preflight required commands and report the missing dependency and lane clearly. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 36d4425157..ca5fff69df 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,7 +35,7 @@ links, and `npm run docs:preview` to preview that build at `/rea/`. Site navigation lives in `docs/.vitepress/config.ts`. Keep links to guides relative so they work on GitHub and the website; link to repository files outside `docs/` using their full GitHub URLs. Generated reference documents -still use `npm run docs:generate`; `docs:build` only builds the website. +use `npm run docs:generate`; `docs:build` generates them before building the website. Pull requests run `npm run docs:check`. `.github/workflows/pages.yml` is a manual VitePress build and does not publish. The public site at @@ -74,8 +74,24 @@ require the matching real-provider `verify:*` lane. Formatting uses Oxfmt and the committed `.oxfmtrc.json`; generated sources use the same configuration. Pre-commit formats and lints staged files; pre-push runs `check:fast`. -`docs:check` checks committed generated metadata. `docs:generate` regenerates -those files, and the docs CI lane checks them. +`docs:check` builds and validates generated metadata for the current checkout. +The product catalog (`docs/public/product-catalog.json`), portable managed +conformance projections (`docs/verification/managed-conformance-*.json`), and +packaged skill (`skills/`) are ignored build outputs. Edit skill instructions +and references in `skill-src/`; the build adds catalog-dependent metadata to +the packaged copy without rewriting authored files. The generated manifest +commits to that exact packaged skill bundle. It is a portable projection of +the deterministic managed verifier, not a record of optional real-provider runs. +CI validates these outputs and retains them as artifacts instead of pushing +generated-only commits onto feature branches. Reviewed source metadata such as +`src/generatedPackageMetadata.ts` and `docs/error-contract.schema.json` remains +tracked and checked for freshness. Do not commit ignored generated outputs. +The build-generated product catalog contains documented facts and their provider +identity, rather than full runtime schema hashes. The managed skill contains +instructions and inventory metadata; doctor compares its installed files with +the canonical bundle. Schema-only fixes should not change these outputs or the +skill commitment in the conformance manifest. Runtime schema identity remains +available through doctor and `binary_session`. Real-provider execution remains uncached; deterministic builds use Turbo. Local `npm test` runs every deterministic Vitest project without coverage or diff --git a/README.md b/README.md index 225f13db8e..94800af9c3 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,16 @@ [![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents) [![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml) -[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/product-catalog.json) +[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog) [![Node.js 22+](https://img.shields.io/badge/Node.js-22.19%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/) [![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything) [![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE) [![Discord](https://img.shields.io/discord/1556595354999332884?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/GkcryMnJDM) +🎉 **20,000 GitHub stars — thank you!** + +Thanks to everyone using REA, reporting bugs, testing builds, and contributing fixes. + morluto%2Frea | Trendshift **[Website](https://morluto.github.io/rea/) · [Guides](https://morluto.github.io/rea/guides/) · [Showcases](https://morluto.github.io/rea/showcase/)** @@ -296,7 +300,7 @@ For exact options, prerequisites and result contracts: - [Readiness and troubleshooting](docs/installation.md#check-readiness-for-your-task): diagnose one agent or analysis engine. - [CLI and Evidence](docs/cli.md): commands, provider selection, snapshots, import/export and exit statuses. - [MCP contracts](docs/mcp-contracts.md) and [agent prompts](docs/mcp-prompts.md): tool results, sessions and guided investigations. -- [Tool catalog](docs/product-catalog.json): generated inventory of tools, providers and CLI commands. +- [Tool catalog](docs/mcp-contracts.md#generated-catalog): build-generated inventory of tools, providers and CLI commands. - [Roadmap](docs/roadmap.md): planned work and capability trackers. Report vulnerabilities through [SECURITY.md](SECURITY.md). diff --git a/README_ar.md b/README_ar.md index 74e0bf1917..0e8e70fc87 100644 --- a/README_ar.md +++ b/README_ar.md @@ -15,6 +15,10 @@ [![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE) [![Discord](https://img.shields.io/discord/1556595354999332884?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/GkcryMnJDM) +🎉 **وصلنا إلى 20,000 نجمة على GitHub، شكرًا لكم!** + +شكرًا لكل من يستخدم REA ويبلّغ عن الأخطاء ويختبر الإصدارات ويساهم في إصلاحها. + morluto%2Frea | Trendshift **[الموقع (بالإنجليزية)](https://morluto.github.io/rea/) · [أدلة الاستخدام](https://morluto.github.io/rea/guides/) · [دراسة حالة DX-Ball](https://morluto.github.io/rea/showcase/dx-ball/)** diff --git a/README_ja.md b/README_ja.md index 94cc4f0951..f89f9d4432 100644 --- a/README_ja.md +++ b/README_ja.md @@ -15,6 +15,10 @@ [![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE) [![Discord](https://img.shields.io/discord/1556595354999332884?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/GkcryMnJDM) +🎉 **GitHub スターが 20,000 を突破しました。ありがとうございます!** + +REA を使い、問題を報告し、ビルドをテストし、修正を届けてくださる皆さんに感謝します。 + morluto%2Frea | Trendshift **[ウェブサイト(英語)](https://morluto.github.io/rea/) · [ガイド](https://morluto.github.io/rea/guides/) · [DX-Ball の事例](https://morluto.github.io/rea/showcase/dx-ball/)** diff --git a/README_ko.md b/README_ko.md index 02d50cb5c8..7aa2b334ef 100644 --- a/README_ko.md +++ b/README_ko.md @@ -15,6 +15,10 @@ [![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE) [![Discord](https://img.shields.io/discord/1556595354999332884?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/GkcryMnJDM) +🎉 **GitHub 스타 20,000개, 감사합니다!** + +REA를 사용하고, 버그를 제보하고, 빌드를 테스트하고, 수정에 기여해 주신 모든 분께 감사드립니다. + morluto%2Frea | Trendshift **[웹사이트(영문)](https://morluto.github.io/rea/) · [가이드](https://morluto.github.io/rea/guides/) · [DX-Ball 사례](https://morluto.github.io/rea/showcase/dx-ball/)** diff --git a/README_zh.md b/README_zh.md index 250e302011..dea70c179f 100644 --- a/README_zh.md +++ b/README_zh.md @@ -15,6 +15,10 @@ [![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE) [![Discord](https://img.shields.io/discord/1556595354999332884?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/GkcryMnJDM) +🎉 **GitHub Star 突破 20,000,感谢大家!** + +感谢每一位使用 REA、反馈问题、测试构建和贡献修复的朋友。 + morluto%2Frea | Trendshift **[网站(英文)](https://morluto.github.io/rea/) · [使用指南](https://morluto.github.io/rea/guides/) · [DX-Ball 案例](https://morluto.github.io/rea/showcase/dx-ball/)** diff --git a/bridge/mitmproxy/capture.py b/bridge/mitmproxy/capture.py index cc24331fe2..fa0535c027 100644 --- a/bridge/mitmproxy/capture.py +++ b/bridge/mitmproxy/capture.py @@ -133,8 +133,6 @@ def project_record(state, secrets): for key, item in value.items(): if not isinstance(key, str): raise CaptureFailure("format", "Native record has a non-string dictionary key.", path) - if key == "__proto__": - raise CaptureFailure("unsupported", "Native object contains a __proto__ member that the current JSON schema boundary cannot preserve.", path) if any(secret in key for secret in secrets): # Validate the omitted subtree too, then discard all of its # coordinates rather than inventing a replacement identity. diff --git a/bridge/process/ProcessRunTokenReader.swift b/bridge/process/ProcessRunTokenReader.swift index f5c8da4908..1438f81577 100644 --- a/bridge/process/ProcessRunTokenReader.swift +++ b/bridge/process/ProcessRunTokenReader.swift @@ -112,6 +112,31 @@ enum RunTokenReadError: Error { case ambiguousEnvironmentBoundary } +@_silgen_name("csops") +private func csops( + _ pid: Int32, + _ ops: UInt32, + _ addr: UnsafeMutableRawPointer?, + _ size: Int +) -> Int32 + +private let csOpsStatus: UInt32 = 0 +private let csPlatformBinary: UInt32 = 0x0400_0000 + +/** + * Whether the target image is an Apple platform binary. Current macOS omits + * the environment of platform binaries from KERN_PROCARGS2 for every caller, + * so this distinguishes a policy-withheld environment from a process whose + * environment is genuinely absent or transiently unreadable. + */ +func isPlatformBinary(pid: Int32) -> Bool { + var flags: UInt32 = 0 + let result = withUnsafeMutablePointer(to: &flags) { pointer in + csops(pid, csOpsStatus, UnsafeMutableRawPointer(pointer), MemoryLayout.size) + } + return result == 0 && (flags & csPlatformBinary) != 0 +} + private func readRunToken(pid: Int32, pointerSize: Int) -> RunTokenObservation { var mib = [CTL_KERN, KERN_PROCARGS2, Int32(pid)] var size = 0 @@ -133,7 +158,12 @@ private func readRunToken(pid: Int32, pointerSize: Int) -> RunTokenObservation { } catch RunTokenReadError.invalidTokenEncoding { return unavailable(pid: pid, reason: "invalid_run_token_encoding") } catch RunTokenReadError.environmentUnavailable { - return unavailable(pid: pid, reason: "environment_unavailable") + return unavailable( + pid: pid, + reason: isPlatformBinary(pid: pid) + ? "platform_binary_environment_withheld" + : "environment_unavailable" + ) } catch RunTokenReadError.appleVectorUnavailable { return unavailable(pid: pid, reason: "apple_vector_unavailable") } catch RunTokenReadError.ambiguousEnvironmentBoundary { diff --git a/docs/binary-diagnostics.md b/docs/binary-diagnostics.md index a833edc36b..e337e0fa1d 100644 --- a/docs/binary-diagnostics.md +++ b/docs/binary-diagnostics.md @@ -27,7 +27,7 @@ REA_PWNTOOLS_PYTHON=/absolute/isolated-env/bin/python \ REA never installs Python, packages or GDB, changes a user init file, launches the selected object as a host process, or requests runtime library resolution. The Python process uses isolated mode and an owned cache. Exact upstream profiles are recorded in -[upstream provenance](../third_party/pwntools/README.md). +[upstream provenance](https://github.com/morluto/rea/blob/main/third_party/pwntools/README.md). ## Interpreting results diff --git a/docs/evm-bytecode.md b/docs/evm-bytecode.md index 5c0c67ae67..8db25973ed 100644 --- a/docs/evm-bytecode.md +++ b/docs/evm-bytecode.md @@ -24,7 +24,7 @@ their distinct unavailable-provider or capability classification. The host must already provide util-linux `prlimit`, normally `/usr/bin/prlimit`; `REA_EVM_PRLIMIT_COMMAND` can select another absolute executable. REA installs no system tools and changes no user configuration. [Upstream provenance and -license](../third_party/evmole/README.md) are packaged. +license](https://github.com/morluto/rea/blob/main/third_party/evmole/README.md) are packaged. ## Evidence semantics diff --git a/docs/installation.md b/docs/installation.md index 67f7bcd874..bc21b585b1 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -95,7 +95,7 @@ npx skills add morluto/rea --skill reverse-engineer-anything This installs agent instructions and bundled references, not REA MCP registration or analysis engines. Follow the skill's -[conditional connection guide](https://github.com/morluto/rea/blob/main/skills/reverse-engineer-anything/SKILL.md#connect-only-when-needed). +[conditional connection guide](https://github.com/morluto/rea/blob/main/skill-src/reverse-engineer-anything/SKILL.md#connect-only-when-needed). Working tools can be used immediately. If tools are missing, inspect the current client's registration with `doctor --client codex --json` (substitute its client ID), then plan repairs with `setup --client codex --dry-run --json`. Show and diff --git a/docs/managed-code-analysis.md b/docs/managed-code-analysis.md index a405b1f4b1..b0a0df4c04 100644 --- a/docs/managed-code-analysis.md +++ b/docs/managed-code-analysis.md @@ -25,7 +25,7 @@ part of the current tool set. This guide describes the implementation and verification of [ADR-0003](adr/0003-managed-code-evidence-and-provider-boundary.md). The canonical -tool inventory is [`product-catalog.json`](https://github.com/morluto/rea/blob/main/docs/product-catalog.json). +tool inventory is the [build-generated catalog](mcp-contracts.md#generated-catalog). ## Shipped scope diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 70bf2259c0..2ce8f5c3d8 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -1,5 +1,15 @@ # MCP runtime contracts +## Generated catalog + +Run `npm run build:cached` in a source checkout to generate the machine-readable +catalog at `docs/public/product-catalog.json`. The [catalog](product-catalog.json) +is also included in documentation builds. PR CI retains it with the packaged +skill and portable conformance projections in the `generated-docs` artifact. +These outputs describe the exact source revision being built; they are not +checked-in snapshots. For a running server, `binary_session` remains the +authoritative source of catalog identity and tool availability. + ## Identity and discovery `binary_session` reports the active package, server, SDK, and negotiated diff --git a/docs/process-capture.md b/docs/process-capture.md index 38048a4132..78d84bf910 100644 --- a/docs/process-capture.md +++ b/docs/process-capture.md @@ -102,6 +102,12 @@ run the same owned-process cleanup path. Settlement reports whether the sampled process group quiesced or whether cleanup was needed or unverifiable; sampling cannot prove that every short-lived or detached descendant was seen. +When the host withholds an unrelated process’s ownership token, REA leaves that +process untouched and records its PID and reason in `cleanup.unverified_processes` +and process residual unknowns. Successful cleanup verifies the owned group; +it does not attribute those unrelated processes. Related or otherwise unexplained +unreadable processes still prevent successful cleanup. + ## Compare two captures Compare saved capture Evidence with: diff --git a/docs/product-catalog.json b/docs/product-catalog.json deleted file mode 100644 index d705bf469e..0000000000 --- a/docs/product-catalog.json +++ /dev/null @@ -1,722 +0,0 @@ -{ - "package": { - "name": "rea-agents", - "version": "6.0.0", - "sdk": { - "server": "2.3.1", - "client": "2.3.1", - "core": "2.3.1" - }, - "skill_version": "33" - }, - "tools": { - "total": 139, - "families": [ - { - "id": "direct", - "surface": "official-proxy", - "count": 41, - "tools": [ - "address_name", - "address_to_file_offset", - "annotate_native_function", - "comment", - "current_address", - "current_document", - "current_procedure", - "goto_address", - "inline_comment", - "inspect_native_data_type", - "inspect_native_instruction", - "inspect_native_load_image", - "list_bookmarks", - "list_documents", - "list_names", - "list_procedures", - "list_segments", - "list_strings", - "next_address", - "prev_address", - "procedure_address", - "procedure_assembly", - "procedure_callees", - "procedure_callers", - "procedure_info", - "procedure_pseudo_code", - "procedure_references", - "read_bytes", - "read_function_instructions", - "resolve_containing_procedure", - "resolve_native_call_targets", - "search_procedures", - "search_strings", - "set_address_name", - "set_addresses_names", - "set_bookmark", - "set_comment", - "set_current_document", - "set_inline_comment", - "unset_bookmark", - "xrefs" - ] - }, - { - "id": "enhanced", - "surface": "enhanced", - "count": 14, - "tools": [ - "analyze_function", - "analyze_swift_types", - "batch_decompile", - "binary_overview", - "find_xrefs_to_name", - "get_call_graph", - "get_objc_classes", - "get_objc_protocols", - "inspect_native_api", - "inspect_native_dispatch_metadata", - "trace_call_path", - "trace_feature", - "trace_native_ui_action", - "trace_native_values" - ] - }, - { - "id": "native", - "surface": "native-provider", - "count": 10, - "tools": [ - "capture_native_ui_scenario", - "demangle_swift", - "inspect_binary_layout", - "inspect_macho", - "inspect_plist", - "inspect_recorded_crash", - "inspect_signature", - "list_architectures", - "observe_native_calls", - "observe_native_ui" - ] - }, - { - "id": "artifact", - "surface": "artifact-provider", - "count": 7, - "tools": [ - "decode_interface_builder", - "extract_artifact", - "inspect_artifact", - "inspect_asset_catalog", - "inspect_evm_interface", - "inspect_keyed_archive", - "trace_dylib_resolution" - ] - }, - { - "id": "managed", - "surface": "managed", - "count": 7, - "tools": [ - "compare_managed_members", - "import_managed_reconstruction", - "inspect_managed_artifact", - "inspect_managed_members", - "inspect_managed_native_boundaries", - "project_managed_application_graph", - "verify_managed_native_boundaries" - ] - }, - { - "id": "firmware", - "surface": "firmware-provider", - "count": 2, - "tools": ["extract_firmware", "inspect_firmware_regions"] - }, - { - "id": "android", - "surface": "android-provider", - "count": 5, - "tools": [ - "inspect_android_class", - "inspect_android_method", - "inspect_android_package", - "search_android_classes", - "trace_android_references" - ] - }, - { - "id": "browser", - "surface": "browser-provider", - "count": 12, - "tools": [ - "analyze_web_bundle", - "capture_browser_scenario", - "capture_web_screenshot", - "compare_web_captures", - "compare_web_screenshots", - "discover_webmcp_tools", - "inspect_web_event_listeners", - "inspect_web_network_capture", - "inspect_web_page", - "list_browser_targets", - "observe_web_execution", - "observe_web_session" - ] - }, - { - "id": "electron", - "surface": "electron-provider", - "count": 5, - "tools": [ - "analyze_javascript_application", - "capture_electron_scenario", - "inspect_electron_page", - "list_electron_targets", - "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": 13, - "tools": [ - "build_reconstruction_obligation_ledger", - "compare_application_versions", - "compare_javascript_export_shapes", - "compare_source_to_bundle", - "evaluate_reconstruction_coverage", - "export_web_scripts", - "project_android_application_graph", - "project_apple_application_graph", - "recover_javascript_sources", - "trace_application_feature", - "trace_javascript_semantics", - "trace_web_module_imports", - "trace_web_source_location" - ] - }, - { - "id": "session", - "surface": "session", - "count": 21, - "tools": [ - "binary_session", - "build_call_path", - "capture_process_scenario", - "close_binary", - "compare_artifacts", - "compare_bundles", - "compare_functions", - "compare_process_captures", - "correlate_static_and_runtime", - "export_evidence_bundle", - "find_changed_behavior", - "get_evidence_bundle", - "get_navigation_context", - "import_evidence_bundle", - "inspect_address_context", - "list_unknowns", - "open_binary", - "record_unknown", - "update_unknown", - "verify_reconstruction", - "verify_unknown_resolution" - ] - } - ] - }, - "providers": [ - { - "id": "binwalk", - "name": "Binwalk", - "version": "3.1.0", - "capabilities": ["inspect_firmware_regions"] - }, - { - "id": "evmole-interface", - "name": "REA EVM interface adapter", - "version": "evmole@0.9.3", - "capabilities": ["inspect_evm_interface"] - }, - { - "id": "ghidra", - "name": "Ghidra", - "version": null, - "capabilities": [ - "address_name", - "address_to_file_offset", - "analyze_function", - "annotate_native_function", - "inspect_native_data_type", - "inspect_native_instruction", - "inspect_native_load_image", - "list_documents", - "list_names", - "list_procedures", - "list_segments", - "list_strings", - "procedure_address", - "procedure_assembly", - "procedure_callees", - "procedure_callers", - "procedure_info", - "procedure_pseudo_code", - "procedure_references", - "read_bytes", - "read_function_instructions", - "resolve_containing_procedure", - "resolve_native_call_targets", - "search_procedures", - "search_strings", - "xrefs" - ] - }, - { - "id": "har-schema/lossless-json", - "name": "REA HAR capture adapter", - "version": "har-schema@2.0.0;jsonc-parser@3.3.1;lossless-json@4.3.1", - "capabilities": ["inspect_web_network_capture"] - }, - { - "id": "hopper", - "name": "Hopper Disassembler", - "version": null, - "capabilities": [ - "address_name", - "address_to_file_offset", - "analyze_function", - "comment", - "current_address", - "current_document", - "current_procedure", - "goto_address", - "inline_comment", - "list_bookmarks", - "list_documents", - "list_names", - "list_procedures", - "list_segments", - "list_strings", - "next_address", - "prev_address", - "procedure_address", - "procedure_assembly", - "procedure_callees", - "procedure_callers", - "procedure_info", - "procedure_pseudo_code", - "procedure_references", - "read_bytes", - "read_function_instructions", - "resolve_containing_procedure", - "search_procedures", - "search_strings", - "set_address_name", - "set_addresses_names", - "set_bookmark", - "set_comment", - "set_current_document", - "set_inline_comment", - "unset_bookmark", - "xrefs" - ] - }, - { - "id": "ida", - "name": "IDA Pro MCP adapter", - "version": "1", - "capabilities": [ - "analyze_function", - "list_procedures", - "list_strings", - "procedure_address", - "procedure_assembly", - "procedure_callees", - "procedure_callers", - "procedure_pseudo_code", - "read_function_instructions", - "search_procedures", - "search_strings", - "xrefs" - ] - }, - { - "id": "jadx", - "name": "JADX headless", - "version": "0.7.1", - "capabilities": [ - "inspect_android_class", - "inspect_android_method", - "inspect_android_package", - "search_android_classes", - "trace_android_references" - ] - }, - { - "id": "mitmproxy-native-tnetstring", - "name": "REA offline native mitmproxy adapter", - "version": "12.2.3", - "capabilities": ["inspect_web_network_capture"] - }, - { - "id": "native-macos", - "name": "macOS native inspection utilities", - "version": null, - "capabilities": [ - "capture_native_ui_scenario", - "demangle_swift", - "inspect_macho", - "inspect_plist", - "inspect_signature", - "list_architectures", - "observe_native_calls", - "observe_native_ui" - ] - }, - { - "id": "pwntools-elf", - "name": "REA pwntools ELF adapter", - "version": "pwntools@4.15.0;pyelftools@0.33;unicorn@2.1.2", - "capabilities": ["inspect_binary_layout", "inspect_recorded_crash"] - }, - { - "id": "rea-android-application", - "name": "REA Android application workflows", - "version": "1", - "capabilities": ["project_android_application_graph"] - }, - { - "id": "rea-apple-application", - "name": "REA Apple application workflows", - "version": "1", - "capabilities": ["project_apple_application_graph"] - }, - { - "id": "rea-artifact-graph", - "name": "REA safe artifact graph provider", - "version": "1", - "capabilities": [ - "decode_interface_builder", - "extract_artifact", - "inspect_artifact", - "inspect_asset_catalog", - "inspect_keyed_archive", - "trace_dylib_resolution" - ] - }, - { - "id": "rea-cdp-browser", - "name": "REA Chrome DevTools Protocol observation provider", - "version": "2", - "capabilities": [ - "analyze_web_bundle", - "capture_web_screenshot", - "compare_web_captures", - "compare_web_screenshots", - "discover_webmcp_tools", - "inspect_web_event_listeners", - "inspect_web_page", - "list_browser_targets", - "observe_web_execution", - "observe_web_session" - ] - }, - { - "id": "rea-cdp-electron", - "name": "REA Electron file-page CDP observation provider", - "version": "1", - "capabilities": ["inspect_electron_page", "list_electron_targets"] - }, - { - "id": "rea-dotnet-static", - "name": "REA managed static analysis provider", - "version": "1", - "capabilities": [ - "inspect_managed_artifact", - "inspect_managed_members", - "inspect_managed_native_boundaries" - ] - }, - { - "id": "rea-dotnet-workflows", - "name": "REA managed-code workflows", - "version": "1", - "capabilities": [ - "compare_managed_members", - "import_managed_reconstruction", - "project_managed_application_graph", - "verify_managed_native_boundaries" - ] - }, - { - "id": "rea-javascript-application", - "name": "REA JavaScript application analyzer", - "version": "1", - "capabilities": ["analyze_javascript_application"] - }, - { - "id": "rea-javascript-application-workflows", - "name": "REA JavaScript application workflows", - "version": "1", - "capabilities": [ - "build_reconstruction_obligation_ledger", - "compare_application_versions", - "compare_javascript_export_shapes", - "compare_source_to_bundle", - "evaluate_reconstruction_coverage", - "trace_application_feature", - "trace_javascript_semantics" - ] - }, - { - "id": "rea-javascript-runtime-reconciliation", - "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-playwright-electron-active", - "name": "REA Playwright active Electron observation provider", - "version": "1", - "capabilities": ["capture_electron_scenario"] - }, - { - "id": "rea-v8-inspector", - "name": "REA passive Node/Electron V8 Inspector provider", - "version": "1", - "capabilities": [ - "list_javascript_runtime_targets", - "observe_javascript_runtime" - ] - }, - { - "id": "rea-web-script-export", - "name": "REA captured script export", - "version": "1", - "capabilities": [ - "export_web_scripts", - "trace_web_module_imports", - "trace_web_source_location" - ] - }, - { - "id": "unblob", - "name": "Unblob", - "version": "26.6.4", - "capabilities": ["extract_firmware"] - }, - { - "id": "wakaru", - "name": "Wakaru", - "version": "1.13.0", - "capabilities": ["recover_javascript_sources"] - } - ], - "setup_clients": [ - { - "id": "claude_code", - "display_name": "Claude Code", - "format": "json", - "configuration": "managed" - }, - { - "id": "claude_desktop", - "display_name": "Claude Desktop", - "format": "json", - "configuration": "managed" - }, - { - "id": "codex", - "display_name": "Codex", - "format": "toml", - "configuration": "managed" - }, - { - "id": "cursor", - "display_name": "Cursor", - "format": "json", - "configuration": "managed" - }, - { - "id": "gemini_cli", - "display_name": "Gemini CLI", - "format": "json", - "configuration": "managed" - }, - { - "id": "windsurf", - "display_name": "Windsurf", - "format": "json", - "configuration": "managed" - }, - { - "id": "devin", - "display_name": "Devin", - "format": "json", - "configuration": "managed" - }, - { - "id": "opencode", - "display_name": "OpenCode", - "format": "opencode", - "configuration": "managed" - }, - { - "id": "antigravity", - "display_name": "Antigravity", - "format": "json", - "configuration": "managed" - }, - { - "id": "copilot_cli", - "display_name": "GitHub Copilot CLI", - "format": "copilot_cli", - "configuration": "managed" - }, - { - "id": "commandcode", - "display_name": "Command Code", - "format": "commandcode", - "configuration": "managed" - }, - { - "id": "vscode", - "display_name": "VS Code", - "format": "vscode", - "configuration": "managed" - } - ], - "cli": { - "primary_count": 94, - "commands": [ - "address-to-file-offset", - "analyze", - "analyze-javascript-application", - "analyze-web-bundle", - "annotate-native-function", - "build-reconstruction-obligation-ledger", - "capabilities", - "capture-browser-scenario", - "capture-electron-scenario", - "capture-native-ui-scenario", - "capture-process", - "capture-web-screenshot", - "compare", - "compare-application-versions", - "compare-javascript-export-shapes", - "compare-managed-members", - "compare-process-captures", - "compare-source-to-bundle", - "compare-web-captures", - "compare-web-screenshots", - "decode-interface-builder", - "decompile", - "demangle-swift", - "discover-webmcp-tools", - "doctor", - "evaluate-reconstruction-coverage", - "evidence-export", - "evidence-import", - "export-web-scripts", - "extract-artifact", - "extract-firmware", - "function", - "import-managed-reconstruction", - "import-reference-source", - "inspect", - "inspect-android-class", - "inspect-android-method", - "inspect-android-package", - "inspect-artifact", - "inspect-asset-catalog", - "inspect-binary-layout", - "inspect-electron-page", - "inspect-evm-interface", - "inspect-firmware-regions", - "inspect-keyed-archive", - "inspect-macho", - "inspect-managed-artifact", - "inspect-managed-members", - "inspect-managed-native-boundaries", - "inspect-native-api", - "inspect-native-data-type", - "inspect-native-dispatch-metadata", - "inspect-native-instruction", - "inspect-native-load-image", - "inspect-plist", - "inspect-recorded-crash", - "inspect-signature", - "inspect-web-event-listeners", - "inspect-web-network-capture", - "inspect-web-page", - "instructions", - "list-architectures", - "list-browser-targets", - "list-electron-targets", - "list-javascript-runtime-targets", - "observe-javascript-runtime", - "observe-native-calls", - "observe-native-ui", - "observe-web-execution", - "observe-web-session", - "project-android-application-graph", - "project-apple-application-graph", - "project-managed-application-graph", - "providers", - "read-bytes", - "reconcile-javascript-runtime", - "recover-javascript-sources", - "resolve-native-call-targets", - "search", - "search-android-classes", - "setup", - "trace", - "trace-android-references", - "trace-application-feature", - "trace-dylib-resolution", - "trace-javascript-semantics", - "trace-native-ui-action", - "trace-native-values", - "trace-web-module-imports", - "trace-web-source-location", - "uninstall", - "update", - "verify-managed-native-boundaries", - "xrefs" - ], - "aliases": [ - { - "name": "compare-bundles", - "target": "compare" - } - ] - }, - "runtime_catalog": { - "counts": { - "cli_commands": 94, - "mcp_tools": 139, - "mcp_prompts": 6 - }, - "digests": { - "tools_sha256": "f128dcd29092b44884ac315724394c9af393ec551964130b1317d3099b5cd9f7", - "prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d", - "combined_sha256": "5f32503e32d18b44866edeca37bec7c4b371b4c3f2c5c23f37ffefdb13f65f44", - "providers_sha256": "954b499c76514890062d2b6c936d73cb3a9c45412d522a081125a325e81cdec5" - } - } -} diff --git a/docs/releasing.md b/docs/releasing.md index 98aa1174dd..56fc662641 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -144,8 +144,8 @@ before cutting the next checkpoint; otherwise main retains the previous release baseline and can propose an already-published version again. After publication, open a PR from the release branch back to main. Preserve -main's later implementation changes and resolve generated-file conflicts by -regenerating from the combined contracts with the released package version. +main's later implementation changes and regenerate ignored build outputs from +the combined contracts with the released package version. Review and test this synchronization PR, then use a merge commit so the release tag remains in main's ancestry. Keep the released tag unchanged. @@ -163,9 +163,12 @@ git merge-base --is-ancestor rea-agents-5.0.0 origin/main Close any superseded rolling release PR. Future releases repeat the checkpoint procedure from main; never resume automatic release-PR refreshes on main pushes. -The generated-metadata workflow stays limited to pull requests into main so it -does not push commits onto a frozen candidate. Preparation normalizes -`docs/product-catalog.json` on the bot pull request. +Preparation generates and validates the catalog, portable conformance +projections, and packaged skill from the candidate checkout without committing +them. The checkpoint validator binds tracked version authority to the source +SHA; generated-catalog and package checks validate the derived representation. +No generated-metadata workflow pushes follow-up commits onto feature or release +branches. ## Partial publication and retries diff --git a/docs/verification/managed-conformance-ledger.json b/docs/verification/managed-conformance-ledger.json deleted file mode 100644 index 0476f20a40..0000000000 --- a/docs/verification/managed-conformance-ledger.json +++ /dev/null @@ -1,105 +0,0 @@ -{ - "ledger_id": "ecl_132e857a8a93f0b366cd72f9cfa1c338ffd4100460a7834ef69abff936c84472", - "records": [ - { - "claim_id": "managed.application-graph-projection", - "status": "pass", - "evidence_ids": [ - "ev_b061a5a559acb3748cb9784c637f6693f74faeb564ba2b11d6fc32619e757459" - ] - }, - { - "claim_id": "managed.decompiler-reconstruction-import", - "status": "pass", - "evidence_ids": [ - "ev_13f3401a3aaddc3f6d087ad160fa928374e142cdb9861254359b46865b8c992e" - ] - }, - { - "claim_id": "managed.dotnet-framework-x86-pinvoke", - "status": "pass", - "evidence_ids": [ - "ev_6f026216826ca06e080b77ff608f02634fa945fbd2e151b4cd261c1444f8bbb1" - ] - }, - { - "claim_id": "managed.ilspy-reconstruction-oracle", - "status": "unsupported", - "evidence_ids": [ - "ev_65a94c4de03f9c6ebdaff3f381cca377821f349d3ca6dfad47a31cc586fae939" - ] - }, - { - "claim_id": "managed.malformed-metadata", - "status": "pass", - "evidence_ids": [ - "ev_deae4259ea4395623176de40d06c2abcbc2b3a77f0bfb068d76e568d801f45a8" - ] - }, - { - "claim_id": "managed.managed-native-verification", - "status": "pass", - "evidence_ids": [ - "ev_0edcbbcaa1191a4e5324a437f739957b77f9ac45e7897724337f2d6d06b3f9d1" - ] - }, - { - "claim_id": "managed.modern-dotnet-anycpu", - "status": "pass", - "evidence_ids": [ - "ev_d1af50e8dc04cd589385769efd8758e82095425f6e9c1cc4bf2f530c6b434c93" - ] - }, - { - "claim_id": "managed.mvid-and-token-drift", - "status": "pass", - "evidence_ids": [ - "ev_5493f40bc875535488d789a490361abcc1f76bfbdf52536d09ff127442a8e854" - ] - }, - { - "claim_id": "managed.not-managed", - "status": "pass", - "evidence_ids": [ - "ev_03ce0cc0183ee0710f8120e837565129559ab3bc41927df326066370c2341699" - ] - }, - { - "claim_id": "managed.operator-local-manifest", - "status": "unsupported", - "evidence_ids": [ - "ev_9d3e50da1d372c55f46649e69eae702cbe8f0ba6a81d1e46afc3e1a284c81757" - ] - }, - { - "claim_id": "managed.operator-manifest-self-test", - "status": "pass", - "evidence_ids": [ - "ev_f974f4b9d039cda841894c6173cae86427ecef48f4e7a4d6c73edf7b23af2b2a" - ] - }, - { - "claim_id": "managed.unicode-obfuscated-identifiers", - "status": "pass", - "evidence_ids": [ - "ev_c4f38ce76d8359d92ee8e9442503e47b7913550442349a0df688bd274a3def67" - ] - }, - { - "claim_id": "managed.x64-ready-to-run-native-body", - "status": "pass", - "evidence_ids": [ - "ev_3e05ef1d6a796d3d4cb3372fe8e549efec759c59bf09d8ff660059a2e448cbc0" - ] - } - ], - "summary": { - "total": 13, - "pass": 11, - "fail": 0, - "unsupported": 2, - "truncated": 0, - "unknown": 0, - "complete": false - } -} diff --git a/docs/verification/managed-conformance-manifest.json b/docs/verification/managed-conformance-manifest.json deleted file mode 100644 index b54b9495ee..0000000000 --- a/docs/verification/managed-conformance-manifest.json +++ /dev/null @@ -1,201 +0,0 @@ -{ - "manifest_id": "ecm_1e391ef1556bdd58a1b304ec175cda45670eeb66154394ccbfee5cd63852151a", - "verifier": { - "id": "rea-managed-conformance", - "version": "1" - }, - "environment": { - "platform": "portable", - "architecture": "portable", - "runtime": "node", - "runtime_version": "portable" - }, - "skill_digests": [ - { - "skill_id": "reverse-engineer-anything", - "sha256": "c1895dc7a01dac0c470881c0b09194f8abc6e009bff6e658bb559d4f7bd0049d" - } - ], - "claims": [ - { - "claim_id": "managed.application-graph-projection", - "scenario": { - "id": "managed-application-graph-projection", - "version": 1 - }, - "artifact_sha256s": [ - "f17fe6286cb07c6385e786e0aa877564d44123197f02617dfec70c537a33e4ea" - ], - "provider": { - "id": "rea-managed-conformance", - "version": "1" - } - }, - { - "claim_id": "managed.decompiler-reconstruction-import", - "scenario": { - "id": "decompiler-reconstruction-import", - "version": 1 - }, - "artifact_sha256s": [ - "cead4cc99733e4e56a5fd0cec8a0813bbc24e249aa72a10eca4c47ee3b0e129d" - ], - "provider": { - "id": "rea-managed-conformance", - "version": "1" - } - }, - { - "claim_id": "managed.dotnet-framework-x86-pinvoke", - "scenario": { - "id": "dotnet-framework-x86-pinvoke", - "version": 1 - }, - "artifact_sha256s": [ - "f17fe6286cb07c6385e786e0aa877564d44123197f02617dfec70c537a33e4ea" - ], - "provider": { - "id": "rea-managed-conformance", - "version": "1" - } - }, - { - "claim_id": "managed.ilspy-reconstruction-oracle", - "scenario": { - "id": "ilspy-reconstruction-oracle", - "version": 1 - }, - "artifact_sha256s": [], - "provider": { - "id": "rea-managed-conformance", - "version": "1" - } - }, - { - "claim_id": "managed.malformed-metadata", - "scenario": { - "id": "malformed-metadata", - "version": 1 - }, - "artifact_sha256s": [ - "827e10b6391d29edca48c191e2ddd79f3bc1165e46e4ba2f4edf41ab60f45e78" - ], - "provider": { - "id": "rea-managed-conformance", - "version": "1" - } - }, - { - "claim_id": "managed.managed-native-verification", - "scenario": { - "id": "managed-native-verification", - "version": 1 - }, - "artifact_sha256s": [ - "9999999999999999999999999999999999999999999999999999999999999999", - "f17fe6286cb07c6385e786e0aa877564d44123197f02617dfec70c537a33e4ea" - ], - "provider": { - "id": "rea-managed-conformance", - "version": "1" - } - }, - { - "claim_id": "managed.modern-dotnet-anycpu", - "scenario": { - "id": "modern-dotnet-anycpu", - "version": 1 - }, - "artifact_sha256s": [ - "92b588ae43d418117fa108c0004b5d0451f6112238d21bad8d75c0eb1da860f0" - ], - "provider": { - "id": "rea-managed-conformance", - "version": "1" - } - }, - { - "claim_id": "managed.mvid-and-token-drift", - "scenario": { - "id": "mvid-and-token-drift", - "version": 1 - }, - "artifact_sha256s": [ - "276395c671a656ab57d583158443c10de552dccfb37dc97cd013f673c0f86b43", - "ef009dab7424fdeba996f2524dbe86fd899c96394498cca0463fcfdb228c5572" - ], - "provider": { - "id": "rea-managed-conformance", - "version": "1" - } - }, - { - "claim_id": "managed.not-managed", - "scenario": { - "id": "not-managed", - "version": 1 - }, - "artifact_sha256s": [ - "de74dab8ab034054a811d4248efcda0bed505187a061a806e274ae87bc453754" - ], - "provider": { - "id": "rea-managed-conformance", - "version": "1" - } - }, - { - "claim_id": "managed.operator-local-manifest", - "scenario": { - "id": "operator-local-manifest", - "version": 1 - }, - "artifact_sha256s": [], - "provider": { - "id": "rea-managed-conformance", - "version": "1" - } - }, - { - "claim_id": "managed.operator-manifest-self-test", - "scenario": { - "id": "operator-manifest-self-test", - "version": 1 - }, - "artifact_sha256s": [ - "cdc11ec7fd0f392e43b9b23971cb41ec49fd0a997fc33c24fcc553cd4386ae37" - ], - "provider": { - "id": "rea-managed-conformance", - "version": "1" - } - }, - { - "claim_id": "managed.unicode-obfuscated-identifiers", - "scenario": { - "id": "unicode-obfuscated-identifiers", - "version": 1 - }, - "artifact_sha256s": [ - "cead4cc99733e4e56a5fd0cec8a0813bbc24e249aa72a10eca4c47ee3b0e129d" - ], - "provider": { - "id": "rea-managed-conformance", - "version": "1" - } - }, - { - "claim_id": "managed.x64-ready-to-run-native-body", - "scenario": { - "id": "x64-ready-to-run-native-body", - "version": 1 - }, - "artifact_sha256s": [ - "e47402428322cdcb06f561036d7d108528cbc6e262d92a21f869218a20c80bfb" - ], - "provider": { - "id": "rea-managed-conformance", - "version": "1" - } - } - ] -} diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index 5216c2031b..01c2041c6f 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -32,9 +32,9 @@ HAR uses unchanged `har-schema@2.0.0` draft-06 schemas, `ajv@8.20.0`, `lossless-json@4.3.1` numeric representations, pinned in the npm lockfile. Only the HAR 1.2 profile is supported. Unknown extensions remain reported evidence. Duplicate object keys, including equal values and escaped spellings, -are rejected before materialization. A `__proto__` member returns an explicit -unsupported-boundary error in both formats: the current shared JSON schema -cannot preserve this member. Original capture files remain unchanged. +are rejected before materialization. Prototype-named members such as +`__proto__` are preserved as ordinary own JSON members without prototype +mutation. Original capture files remain unchanged. The observed mitmproxy 12.2.3 `SaveHar` profile can emit `postData.text: null` for a missing request body. REA preserves that reported null, omitting the optional field only from its validation copy for this exact producer profile; diff --git a/package.json b/package.json index 61626eab6d..4f35947ed5 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ "mcp-catalog:check": "node scripts/generate-mcp-tool-catalog.mjs --check", "prebuild": "npm run deps:check && npm run metadata:check", "build": "node scripts/run-exclusive.mjs build npm run build:unlocked", - "build:unlocked": "node scripts/clean-build-output.mjs && tsc -p tsconfig.build.json && node scripts/generate-mcp-tool-catalog.mjs", + "build:unlocked": "node scripts/clean-build-output.mjs && tsc -p tsconfig.build.json && node scripts/generate-mcp-tool-catalog.mjs && node scripts/generate-skill-metadata.mjs && node scripts/generate-product-catalog.mjs && node scripts/generate-completion-ledger.mjs", "precheck": "npm run deps:check", "check": "turbo run typecheck lint format:check knip metadata:check", "precheck:fast": "npm run deps:check", @@ -180,7 +180,9 @@ "config:print": "node scripts/print-mcp-config.mjs", "evidence:generate": "npm run build:cached && node scripts/generate-completion-ledger.mjs", "evidence:check": "npm run build:cached && node scripts/generate-completion-ledger.mjs --check", + "predocs:dev": "npm run docs:generate", "docs:dev": "vitepress dev docs", + "predocs:build": "npm run docs:generate", "docs:build": "vitepress build docs", "docs:preview": "vitepress preview docs", "docs:generate": "node scripts/run-exclusive.mjs docs npm run docs:generate:unlocked", diff --git a/release-please-config.json b/release-please-config.json index 85a7eee3b9..4560b53fa0 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -34,11 +34,6 @@ "type": "json", "path": "server.json", "jsonpath": "$.packages[0].version" - }, - { - "type": "json", - "path": "docs/product-catalog.json", - "jsonpath": "$.package.version" } ] } diff --git a/scripts/generate-completion-ledger.mjs b/scripts/generate-completion-ledger.mjs index fa3bf6b4a0..ce63ffd40c 100644 --- a/scripts/generate-completion-ledger.mjs +++ b/scripts/generate-completion-ledger.mjs @@ -30,7 +30,7 @@ const { stdout } = await exec( }, ); const verifierOutput = JSON.parse(stdout); -// Managed conformance claims are host-independent. Keep the checked-in +// Managed conformance claims are host-independent. Keep the generated // commitment reproducible across macOS, Linux, and Windows rather than // embedding the machine that happened to generate it. const portableCompletionReport = { diff --git a/scripts/generate-package-metadata.mjs b/scripts/generate-package-metadata.mjs index 2a23368397..8247dac1c2 100644 --- a/scripts/generate-package-metadata.mjs +++ b/scripts/generate-package-metadata.mjs @@ -17,7 +17,7 @@ const lock = JSON.parse( await readFile(join(root, "package-lock.json"), "utf8"), ); const skill = await readFile( - join(root, "skills/reverse-engineer-anything/SKILL.md"), + join(root, "skill-src/reverse-engineer-anything/SKILL.md"), "utf8", ); const skillVersion = /^\s{2}version:\s*"([^"]+)"\s*$/mu.exec(skill)?.[1]; diff --git a/scripts/generate-product-catalog.mjs b/scripts/generate-product-catalog.mjs index c13e80c3e2..fa6aa4ad8f 100644 --- a/scripts/generate-product-catalog.mjs +++ b/scripts/generate-product-catalog.mjs @@ -1,3 +1,4 @@ +import { mkdir } from "node:fs/promises"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -14,8 +15,10 @@ for (const argument of arguments_) const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const catalog = await createProductCatalog(root); +const path = join(root, "docs/public/product-catalog.json"); +if (!arguments_.has("--check")) await mkdir(dirname(path), { recursive: true }); await ensureGeneratedFile({ - path: join(root, "docs/product-catalog.json"), + path, source: await serializeProductCatalog(catalog), check: arguments_.has("--check"), generateCommand: "npm run docs:generate", diff --git a/scripts/generate-skill-metadata.mjs b/scripts/generate-skill-metadata.mjs index 3d4bc4bbca..490976fb51 100644 --- a/scripts/generate-skill-metadata.mjs +++ b/scripts/generate-skill-metadata.mjs @@ -1,4 +1,4 @@ -import { readFile } from "node:fs/promises"; +import { mkdir, readFile, readdir, rm } from "node:fs/promises"; import { dirname, join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -10,26 +10,69 @@ for (const argument of arguments_) throw new Error(`Unknown skill metadata option: ${argument}`); const root = join(dirname(fileURLToPath(import.meta.url)), ".."); -const path = join(root, "skills/reverse-engineer-anything/SKILL.md"); -const current = await readFile(path, "utf8"); +const sourceRoot = join(root, "skill-src/reverse-engineer-anything"); +const outputRoot = join(root, "skills/reverse-engineer-anything"); +const check = arguments_.has("--check"); +const current = ( + await readFile(join(sourceRoot, "SKILL.md"), "utf8") +).replaceAll("\r\n", "\n"); const { CATALOG_IDENTITY } = await import( `${pathToFileURL(join(root, "dist/catalogIdentity.js")).href}?${String(Date.now())}` ); -const withCount = current.replace( - /^\s{2}tool_count:\s*\d+\s*$/mu, - ` tool_count: ${String(CATALOG_IDENTITY.counts.mcp_tools)}`, +if (/^\s{2}(?:tool_count|catalog_digest):/mu.test(current)) + throw new Error( + "Catalog metadata belongs in the generated skill, not skill-src", + ); +const versionLine = /^ {2}version: "[^"\r\n]+"$/mu; +if (!versionLine.test(current)) + throw new Error("Missing authored skill version"); +const source = current.replace( + versionLine, + `$&\n tool_count: ${String(CATALOG_IDENTITY.counts.mcp_tools)}`, ); -const digestLine = ` catalog_digest: "${CATALOG_IDENTITY.digests.combined_sha256}"`; -const source = /^\s{2}catalog_digest:/mu.test(withCount) - ? withCount.replace( - /^\s{2}catalog_digest:\s*"[a-f0-9]{64}"\s*$/mu, - digestLine, - ) - : withCount.replace(/^(\s{2}tool_count:\s*\d+\s*)$/mu, `$1\n${digestLine}`); +const paths = await filePaths(sourceRoot); +// Rebuild this owned output directory so removed references cannot survive a build. +if (!check) await rm(outputRoot, { recursive: true, force: true }); +for (const relativePath of paths) { + const path = join(outputRoot, relativePath); + if (!check) await mkdir(dirname(path), { recursive: true }); + await ensureGeneratedFile({ + path, + source: + relativePath === "SKILL.md" + ? source + : (await readFile(join(sourceRoot, relativePath), "utf8")).replaceAll( + "\r\n", + "\n", + ), + check, + generateCommand: "npm run docs:generate", + }); +} +if ( + check && + JSON.stringify(await filePaths(outputRoot)) !== JSON.stringify(paths) +) + throw new Error( + "Generated skill file inventory drifted; run npm run docs:generate", + ); -await ensureGeneratedFile({ - path, - source, - check: arguments_.has("--check"), - generateCommand: "npm run docs:generate", -}); +async function filePaths(directory, prefix = "") { + const paths = []; + for (const entry of (await readdir(directory, { withFileTypes: true })).sort( + (left, right) => + left.name < right.name ? -1 : left.name > right.name ? 1 : 0, + )) { + const relativePath = prefix === "" ? entry.name : `${prefix}/${entry.name}`; + if (entry.isDirectory()) + paths.push( + ...(await filePaths(join(directory, entry.name), relativePath)), + ); + else if (entry.isFile()) paths.push(relativePath); + else + throw new Error( + `Skill bundle requires regular files or directories: ${relativePath}`, + ); + } + return paths; +} diff --git a/scripts/lib/docs-facts.mjs b/scripts/lib/docs-facts.mjs index ad50dbc8e3..d45d545aa7 100644 --- a/scripts/lib/docs-facts.mjs +++ b/scripts/lib/docs-facts.mjs @@ -77,7 +77,12 @@ export const documentationFactIssues = async (root, catalog) => { requireText(issues, path, content, "MCP-tool_catalog"); if (path === "README.md") { requireText(issues, path, content, "(docs/installation.md"); - requireText(issues, path, content, "(docs/product-catalog.json)"); + requireText( + issues, + path, + content, + "(docs/mcp-contracts.md#generated-catalog)", + ); continue; } for (const client of catalog.setup_clients) @@ -99,11 +104,16 @@ export const documentationFactIssues = async (root, catalog) => { requireText(issues, installationPath, installation, client.display_name); const agents = await readFile(join(root, "AGENTS.md"), "utf8"); - requireText(issues, "AGENTS.md", agents, "docs/product-catalog.json"); + requireText(issues, "AGENTS.md", agents, "docs/public/product-catalog.json"); const templatePath = ".github/pull_request_template.md"; const template = await readFile(join(root, templatePath), "utf8"); - requireText(issues, templatePath, template, "docs/product-catalog.json"); + requireText( + issues, + templatePath, + template, + "docs/public/product-catalog.json", + ); return issues; }; diff --git a/scripts/lib/product-catalog.mjs b/scripts/lib/product-catalog.mjs index 8c4aaae4f9..266e37291f 100644 --- a/scripts/lib/product-catalog.mjs +++ b/scripts/lib/product-catalog.mjs @@ -58,16 +58,16 @@ export const createProductCatalog = async (root) => { runtime_catalog: { counts: sources.catalogIdentity.CATALOG_IDENTITY.counts, digests: { - ...sources.catalogIdentity.CATALOG_IDENTITY.digests, + // Full runtime schema digests belong to server identity, not this facts projection. providers_sha256: providerCatalogDigest(providers), }, }, }; }; -/** Stable checked-in representation of the product catalog. */ +/** Stable build-generated representation of the product catalog. */ export const serializeProductCatalog = (catalog) => formatGeneratedFile( - "docs/product-catalog.json", + "docs/public/product-catalog.json", JSON.stringify(catalog, null, 2), ); diff --git a/scripts/lib/release-checkpoint.mjs b/scripts/lib/release-checkpoint.mjs index 201cbb179f..1d1950d5fb 100644 --- a/scripts/lib/release-checkpoint.mjs +++ b/scripts/lib/release-checkpoint.mjs @@ -180,13 +180,8 @@ async function validateCandidate(git, sha, version, commits) { ["server.json", server.version], ["server.json rea-agents package", npmPackage.version], ); - const catalog = await readJson( - git, - sha, - "docs/product-catalog.json", - z.object({ package: versionSchema }), - ); - versions.push(["docs/product-catalog.json", catalog.package.version]); + // Catalogs are generated and checked from this candidate by the release job; + // they are not version authority stored in the immutable source tree. const generated = await git([ "show", `${sha}:src/generatedPackageMetadata.ts`, diff --git a/scripts/verify-binary-layout.mjs b/scripts/verify-binary-layout.mjs index 8b83d2c293..280ec58f15 100644 --- a/scripts/verify-binary-layout.mjs +++ b/scripts/verify-binary-layout.mjs @@ -1169,7 +1169,7 @@ try { ); const ownershipInspection = ["/usr/bin/ps", "/bin/ps"].includes(match[1]) && - line.includes('["ps", "-axo", "pid=,ppid=,pgid=,stat=,command="]'); + line.includes('["ps", "-axo", "pid=,ppid=,pgid=,uid=,stat=,command="]'); assert.ok( [process.execPath, python].includes(match[1]) || ownershipInspection, `Unexpected attempted host execution: ${line}`, diff --git a/scripts/verify-evm-interface.mjs b/scripts/verify-evm-interface.mjs index 39cb2b356c..88c37b649a 100644 --- a/scripts/verify-evm-interface.mjs +++ b/scripts/verify-evm-interface.mjs @@ -474,7 +474,9 @@ try { ); const ownershipInspection = ["/usr/bin/ps", "/bin/ps"].includes(match[1]) && - line.includes('["ps", "-axo", "pid=,ppid=,pgid=,stat=,command="]'); + line.includes( + '["ps", "-axo", "pid=,ppid=,pgid=,uid=,stat=,command="]', + ); assert.ok( [ process.execPath, diff --git a/scripts/verify-package-pack.mjs b/scripts/verify-package-pack.mjs index 8cae7bf4a2..600ecf7a03 100644 --- a/scripts/verify-package-pack.mjs +++ b/scripts/verify-package-pack.mjs @@ -1,4 +1,5 @@ import { join } from "node:path"; +import { CATALOG_IDENTITY } from "../dist/catalogIdentity.js"; import { exec } from "./lib/verify-package-core.mjs"; import { verifyPackedBridge } from "./verify-packed-bridge.mjs"; @@ -28,6 +29,26 @@ export async function verifyPackagePack({ root, workspace }) { throw new Error("package did not expose both rea command entry points"); if (packedManifest.mcpName !== "io.github.morluto/rea") throw new Error("package did not retain its MCP Registry ownership marker"); + if (packedFiles.some((path) => path.startsWith("package/skill-src/"))) + throw new Error( + "package included authored skill sources instead of only the generated bundle", + ); + const skill = ( + await exec("tar", [ + "-xOf", + join(root, tarball), + "package/skills/reverse-engineer-anything/SKILL.md", + ]) + ).stdout; + if ( + !skill.includes( + ` tool_count: ${String(CATALOG_IDENTITY.counts.mcp_tools)}\n`, + ) || + skill.includes(" catalog_digest:") + ) + throw new Error( + "packaged skill metadata did not match the catalog shipped in the package", + ); if ( packedFiles.some( (path) => path.includes("__pycache__") || path.endsWith(".pyc"), diff --git a/scripts/verify-web-network-captures.mjs b/scripts/verify-web-network-captures.mjs index d5d1e2609a..316d811ce9 100644 --- a/scripts/verify-web-network-captures.mjs +++ b/scripts/verify-web-network-captures.mjs @@ -260,14 +260,16 @@ try { ); const original = await readFile(path); for (const mode of ["cli", "mcp"]) { - const error = await inspect( - mode, - { capture_path: path, format }, - "unsupported_provider", - ); - assert.ok( - JSON.stringify(error).includes("JSON schema boundary cannot preserve"), - ); + const value = await inspect(mode, { capture_path: path, format }); + const reported = + format === "har" + ? value.container.reported._extension + : value.records[0].reported.metadata; + assert.equal(Object.hasOwn(reported, "__proto__"), true); + assert.deepEqual(reported["__proto__"], { preserved: 7 }); + assert.equal(Object.getPrototypeOf(reported), Object.prototype); + assert.equal(Reflect.get(Object.prototype, "preserved"), undefined); + if (format === "har") assert.equal(reported.constructor, "ordinary"); assert.deepEqual(await readFile(path), original); cases++; } diff --git a/scripts/verify/recorded-crash/trace.mjs b/scripts/verify/recorded-crash/trace.mjs index abe3cecb73..f5f31624ba 100644 --- a/scripts/verify/recorded-crash/trace.mjs +++ b/scripts/verify/recorded-crash/trace.mjs @@ -79,7 +79,7 @@ export async function verifyCoreInspectionTrace({ (line) => /^execve\("\/(?:usr\/)?bin\/ps",/u.test(line) && line.includes( - '["ps", "-axo", "pid=,ppid=,pgid=,stat=,command="]', + '["ps", "-axo", "pid=,ppid=,pgid=,uid=,stat=,command="]', ) && line.endsWith(" = 0"), ); @@ -128,7 +128,9 @@ export async function verifyCoreInspectionTrace({ const executable = await realpath(match[1]).catch(() => match[1]); const ownership = psPaths.has(match[1]) && - line.includes('["ps", "-axo", "pid=,ppid=,pgid=,stat=,command="]'); + line.includes( + '["ps", "-axo", "pid=,ppid=,pgid=,uid=,stat=,command="]', + ); const iconv = iconvPaths.has(match[1]) && line.includes('["iconv", "-l"]'); const version = diff --git a/skills/reverse-engineer-anything/SKILL.md b/skill-src/reverse-engineer-anything/SKILL.md similarity index 99% rename from skills/reverse-engineer-anything/SKILL.md rename to skill-src/reverse-engineer-anything/SKILL.md index 97c2d75031..629a727312 100644 --- a/skills/reverse-engineer-anything/SKILL.md +++ b/skill-src/reverse-engineer-anything/SKILL.md @@ -3,8 +3,6 @@ name: reverse-engineer-anything description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis. metadata: version: "33" - tool_count: 139 - catalog_digest: "5f32503e32d18b44866edeca37bec7c4b371b4c3f2c5c23f37ffefdb13f65f44" --- # REA diff --git a/skills/reverse-engineer-anything/references/android-applications.md b/skill-src/reverse-engineer-anything/references/android-applications.md similarity index 100% rename from skills/reverse-engineer-anything/references/android-applications.md rename to skill-src/reverse-engineer-anything/references/android-applications.md diff --git a/skills/reverse-engineer-anything/references/evidence-workflows.md b/skill-src/reverse-engineer-anything/references/evidence-workflows.md similarity index 100% rename from skills/reverse-engineer-anything/references/evidence-workflows.md rename to skill-src/reverse-engineer-anything/references/evidence-workflows.md diff --git a/skills/reverse-engineer-anything/references/javascript-applications.md b/skill-src/reverse-engineer-anything/references/javascript-applications.md similarity index 100% rename from skills/reverse-engineer-anything/references/javascript-applications.md rename to skill-src/reverse-engineer-anything/references/javascript-applications.md diff --git a/skills/reverse-engineer-anything/references/native-and-artifacts.md b/skill-src/reverse-engineer-anything/references/native-and-artifacts.md similarity index 100% rename from skills/reverse-engineer-anything/references/native-and-artifacts.md rename to skill-src/reverse-engineer-anything/references/native-and-artifacts.md diff --git a/skills/reverse-engineer-anything/references/runtime-observation.md b/skill-src/reverse-engineer-anything/references/runtime-observation.md similarity index 100% rename from skills/reverse-engineer-anything/references/runtime-observation.md rename to skill-src/reverse-engineer-anything/references/runtime-observation.md diff --git a/src/application/ClientRegistrationIdentity.test.ts b/src/application/ClientRegistrationIdentity.test.ts new file mode 100644 index 0000000000..96e8d155c0 --- /dev/null +++ b/src/application/ClientRegistrationIdentity.test.ts @@ -0,0 +1,52 @@ +import { expect, it } from "vitest"; +import { PRODUCT_IDENTITY } from "../identity.js"; +import { + isOwnedClientRegistrationCommand, + npxRegistrationCommand, +} from "./ClientRegistrationIdentity.js"; +import { setupRegistrationCommand } from "./SetupHost.js"; + +it("uses a Windows executable for npm-run setup and preserves POSIX launchers", () => { + expect(setupRegistrationCommand("win32", true)).toEqual([ + "cmd.exe", + "/d", + "/c", + "npx", + "-y", + PRODUCT_IDENTITY.registrationPackageSpecifier, + "mcp", + ]); + for (const platform of ["linux", "darwin"] as const) + expect(setupRegistrationCommand(platform, true)).toEqual([ + "npx", + "-y", + PRODUCT_IDENTITY.registrationPackageSpecifier, + "mcp", + ]); +}); + +it.each( + [ + npxRegistrationCommand("win32"), + ["cmd", "/c", "npx", "-y", "rea-agents@5.0.0", "mcp"], + ["npx", "-y", "rea-agents@5.0.0", "mcp"], + ].map((command) => ({ command })), +)("recognizes owned current and legacy launchers: $command", ({ command }) => { + expect(isOwnedClientRegistrationCommand(command)).toBe(true); +}); + +it.each( + [ + ["cmd.exe", "/d", "/c", "node", "custom.js", "mcp"], + ["cmd.exe", "/k", "npx", "-y", "rea-agents@5.0.0", "mcp"], + ["cmd.exe", "/c", "npx -y rea-agents@5.0.0 mcp"], + ["cmd.exe", "/c", "npx", "-y", "another-package", "mcp"], + ["cmd.exe", "/c", "npx", "-y", "rea-agents@5.0.0", "mcp", "&", "other"], + ["cmd.exe", "/c", "npx", "-y", "rea-agents@5.0.0 & other", "mcp"], + ].map((command) => ({ command })), +)( + "does not claim custom shell commands as REA-owned: $command", + ({ command }) => { + expect(isOwnedClientRegistrationCommand(command)).toBe(false); + }, +); diff --git a/src/application/ClientRegistrationIdentity.ts b/src/application/ClientRegistrationIdentity.ts index 2bc4bd105f..7a887d7c45 100644 --- a/src/application/ClientRegistrationIdentity.ts +++ b/src/application/ClientRegistrationIdentity.ts @@ -3,6 +3,28 @@ import { valid } from "semver"; import { PRODUCT_IDENTITY } from "../identity.js"; +/** Resolve a pinned npx launcher that stdio clients can spawn on the host. */ +export const npxRegistrationCommand = ( + platform: NodeJS.Platform = process.platform, +): readonly string[] => { + const command = [ + "npx", + "-y", + PRODUCT_IDENTITY.registrationPackageSpecifier, + "mcp", + ]; + return platform === "win32" ? ["cmd.exe", "/d", "/c", ...command] : command; +}; + +const unwrapNpxCommand = (command: readonly string[]): readonly string[] => { + const executable = command[0]?.toLowerCase(); + if (executable !== "cmd" && executable !== "cmd.exe") return command; + if (command[1]?.toLowerCase() === "/c") return command.slice(2); + if (command[1]?.toLowerCase() === "/d" && command[2]?.toLowerCase() === "/c") + return command.slice(3); + return []; +}; + /** Check that one parsed command points to REA's MCP entry point. */ export const isOwnedClientRegistrationCommand = ( command: readonly string[], @@ -27,15 +49,16 @@ export const isOwnedClientRegistrationCommand = ( return true; } + const npxCommand = unwrapNpxCommand(command); if ( - command.length !== 4 || - command[0] !== "npx" || - command[1] !== "-y" || - command[3] !== "mcp" + npxCommand.length !== 4 || + npxCommand[0] !== "npx" || + npxCommand[1] !== "-y" || + npxCommand[3] !== "mcp" ) return false; - const packageReference = command[2] ?? ""; + const packageReference = npxCommand[2] ?? ""; if ( packageReference === PRODUCT_IDENTITY.packageName || packageReference === PRODUCT_IDENTITY.packageSpecifier || diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts index 3a820aa8ec..91acc42939 100644 --- a/src/application/ClientRegistrationStatus.ts +++ b/src/application/ClientRegistrationStatus.ts @@ -9,7 +9,10 @@ import { z } from "zod"; import { PRODUCT_IDENTITY } from "../identity.js"; import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js"; -import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js"; +import { + isOwnedClientRegistrationCommand, + npxRegistrationCommand, +} from "./ClientRegistrationIdentity.js"; import { supportedClients } from "./SupportedClients.js"; import type { SetupClient } from "./SupportedClients.js"; @@ -108,7 +111,12 @@ export const readClientRegistrationStatuses = async ( client.name, client.configPath, command, - registrationAligned(registration, client, currentCommandPath) + registrationAligned( + registration, + client, + currentCommandPath, + options.platform ?? process.platform, + ) ? "aligned" : "stale", ), @@ -132,6 +140,7 @@ const registrationAligned = ( registration: z.output, client: SetupClient, currentCommandPath: string, + platform: NodeJS.Platform, ): boolean => { const command = [registration.command, ...registration.args]; if (registration.disabled === true || registration.enabled === false) @@ -160,12 +169,10 @@ const registrationAligned = ( resolve(command[1] ?? "") === currentCommandPath ) return true; + const npxCommand = npxRegistrationCommand(platform); if ( - command.length === 4 && - command[0] === "npx" && - command[1] === "-y" && - command[2] === PRODUCT_IDENTITY.registrationPackageSpecifier && - command[3] === "mcp" + command.length === npxCommand.length && + command.every((argument, index) => argument === npxCommand[index]) ) return true; return ( diff --git a/src/application/Doctor.fixture.ts b/src/application/Doctor.fixture.ts index 2c4725dfac..9f271ed221 100644 --- a/src/application/Doctor.fixture.ts +++ b/src/application/Doctor.fixture.ts @@ -26,7 +26,8 @@ export const createDoctorHostFixture = ( Promise.resolve({ version: PRODUCT_IDENTITY.skillVersion, toolCount: CATALOG_IDENTITY.counts.mcp_tools, - catalogDigest: CATALOG_IDENTITY.digests.combined_sha256, + catalogDigest: null, + canonical: true, }), ...overrides, }); diff --git a/src/application/Doctor.test.ts b/src/application/Doctor.test.ts index af7881c5bf..d6845245a1 100644 --- a/src/application/Doctor.test.ts +++ b/src/application/Doctor.test.ts @@ -177,6 +177,7 @@ describe("doctor installation identity", () => { version: "10", toolCount: null, catalogDigest: null, + canonical: false, }), }), ); diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts index a4cf57e9cc..078db0d73d 100644 --- a/src/application/Doctor.ts +++ b/src/application/Doctor.ts @@ -14,6 +14,7 @@ import { readLinuxDistribution, type LinuxDistribution, } from "./LinuxHopper.js"; +import { canonicalSkillNeedsInstall } from "./SetupSkill.js"; import { CATALOG_IDENTITY } from "../catalogIdentity.js"; import { PRODUCT_IDENTITY, SDK_IDENTITY } from "../identity.js"; import { @@ -95,11 +96,13 @@ export interface DoctorHost { ilspyCmdVersion?(path: string): Promise; } -/** Parsed identity committed by an installed REA skill. */ +/** Observed skill metadata and comparison with the packaged instruction bundle. */ interface InstalledSkillIdentity { readonly version: string | null; readonly toolCount: number | null; readonly catalogDigest: string | null; + /** Whether all managed instruction and reference files match this package. */ + readonly canonical: boolean; } /** Structured result returned by the read-only doctor workflow. */ @@ -139,6 +142,7 @@ interface DoctorIdentity { readonly skill: { readonly installed_version: string | null; readonly installed_tool_count: number | null; + /** Legacy catalog digest observed in older bundles; current bundles omit it. */ readonly installed_catalog_digest: string | null; readonly state: "aligned" | "stale" | "missing"; readonly remediation: string | null; @@ -253,7 +257,7 @@ const skillIdentityAligned = ( ): boolean => identity?.version === PRODUCT_IDENTITY.skillVersion && identity.toolCount === CATALOG_IDENTITY.counts.mcp_tools && - identity.catalogDigest === CATALOG_IDENTITY.digests.combined_sha256; + identity.canonical; const skillIdentityCheck = ( identity: InstalledSkillIdentity | undefined, @@ -507,6 +511,7 @@ const installedSkillIdentity = async ( const content = await readInstalledSkill(home); const countText = /^\s{2}tool_count:\s*(\d+)\s*$/mu.exec(content)?.[1]; return { + canonical: !(await canonicalSkillNeedsInstall(home)), version: /^\s{2}version:\s*"([^"]+)"\s*$/mu.exec(content)?.[1] ?? null, toolCount: countText === undefined ? null : Number.parseInt(countText, 10), diff --git a/src/application/EvidenceBundleFiles.ts b/src/application/EvidenceBundleFiles.ts index 8d9e1d665a..6e4c7cda0a 100644 --- a/src/application/EvidenceBundleFiles.ts +++ b/src/application/EvidenceBundleFiles.ts @@ -1,4 +1,6 @@ import { + describeEvidenceBundleFailure, + describeValidationFailure, parseEvidenceBundle, serializeEvidenceBundle, type EvidenceBundle, @@ -20,20 +22,31 @@ export const readEvidenceBundle = async ( ): Promise> => { const loaded = await readJsonFile(path); if (!loaded.ok) return loaded; + let bundle: EvidenceBundle; try { - const bundle = parseEvidenceBundle(loaded.value); - for (const record of bundle.records) { - if (record.predicate_type === "rea.process-capture") - parseProcessCapture(record.normalized_result); - } - return ok(bundle); + bundle = parseEvidenceBundle(loaded.value); } catch (cause: unknown) { return err( new EvidenceIntegrityError("Evidence bundle validation failed", { cause, + userMessage: describeEvidenceBundleFailure(loaded.value, cause), }), ); } + for (const record of bundle.records) { + if (record.predicate_type !== "rea.process-capture") continue; + try { + parseProcessCapture(record.normalized_result); + } catch (cause: unknown) { + return err( + new EvidenceIntegrityError("Evidence bundle validation failed", { + cause, + userMessage: `Process capture record ${record.evidence_id} has an invalid normalized_result (${describeValidationFailure(cause)}). Recreate or re-export the bundle, then try again.`, + }), + ); + } + } + return ok(bundle); }; /** Atomically write deterministic evidence JSON at the caller-supplied path. */ diff --git a/src/application/JsonFiles.ts b/src/application/JsonFiles.ts index 23a3514ddc..4fea078a86 100644 --- a/src/application/JsonFiles.ts +++ b/src/application/JsonFiles.ts @@ -10,10 +10,14 @@ import { err, ok, type Result } from "../domain/result.js"; export const readJsonFile = async ( path: string, ): Promise> => { + const requestedPath = resolve(path); try { - const canonicalPath = await realpath(resolve(path)); + const canonicalPath = await realpath(requestedPath); const stats = await lstat(canonicalPath); - if (!stats.isFile()) return err(new EvidenceFileError("read", "not-file")); + if (!stats.isFile()) + return err( + new EvidenceFileError("read", "not-file", { path: requestedPath }), + ); const encoded = await readFile(canonicalPath); let decoded: unknown; try { @@ -23,11 +27,21 @@ export const readJsonFile = async ( ), ); } catch (cause: unknown) { - return err(new EvidenceFileError("read", "invalid-json", { cause })); + return err( + new EvidenceFileError("read", "invalid-json", { + cause, + path: requestedPath, + }), + ); } return ok(decoded); } catch (cause: unknown) { - return err(new EvidenceFileError("read", "io", { cause })); + return err( + new EvidenceFileError("read", missingOrIo(cause), { + cause, + path: requestedPath, + }), + ); } }; @@ -40,8 +54,8 @@ export const writeTextFile = async ( Result<{ readonly path: string; readonly bytes: number }, EvidenceFileError> > => { const bytes = Buffer.byteLength(encoded, "utf8"); + const requestedPath = resolve(path); try { - const requestedPath = resolve(path); const canonicalParent = await realpath(dirname(requestedPath)); const destination = resolve(canonicalParent, basename(requestedPath)); const existing = await lstat(destination).catch((cause: unknown) => { @@ -49,9 +63,14 @@ export const writeTextFile = async ( throw cause; }); if (existing !== undefined) { - if (!overwrite) return err(new EvidenceFileError("write", "exists")); + if (!overwrite) + return err( + new EvidenceFileError("write", "exists", { path: requestedPath }), + ); if (!existing.isFile() || existing.isSymbolicLink()) - return err(new EvidenceFileError("write", "not-file")); + return err( + new EvidenceFileError("write", "not-file", { path: requestedPath }), + ); } if (overwrite) await writeFileAtomic(destination, encoded, { @@ -63,11 +82,27 @@ export const writeTextFile = async ( return ok({ path: requestedPath, bytes }); } catch (cause: unknown) { if (!overwrite && fileErrorCode(cause) === "EEXIST") - return err(new EvidenceFileError("write", "exists", { cause })); - return err(new EvidenceFileError("write", "io", { cause })); + return err( + new EvidenceFileError("write", "exists", { + cause, + path: requestedPath, + }), + ); + return err( + new EvidenceFileError("write", missingOrIo(cause), { + cause, + path: requestedPath, + }), + ); } }; +/** A missing path or parent is a selection error, not a permission failure. */ +const missingOrIo = (cause: unknown): "missing" | "io" => { + const code = fileErrorCode(cause); + return code === "ENOENT" || code === "ENOTDIR" ? "missing" : "io"; +}; + const publishNewFile = async ( destination: string, encoded: string, diff --git a/src/application/SetupClientConfiguration.ts b/src/application/SetupClientConfiguration.ts index a9a231d99a..d59b06f179 100644 --- a/src/application/SetupClientConfiguration.ts +++ b/src/application/SetupClientConfiguration.ts @@ -15,6 +15,7 @@ import { dirname } from "node:path"; import writeFileAtomic from "write-file-atomic"; import { PRODUCT_IDENTITY } from "../identity.js"; +import { npxRegistrationCommand } from "./ClientRegistrationIdentity.js"; import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js"; import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js"; import type { @@ -24,12 +25,7 @@ import type { } from "./SetupTypes.js"; import type { SetupClient } from "./SupportedClients.js"; -const defaultCommand = (): readonly string[] => [ - "npx", - "-y", - PRODUCT_IDENTITY.registrationPackageSpecifier, - "mcp", -]; +const defaultCommand = (): readonly string[] => npxRegistrationCommand(); /** Back up, atomically update, and semantically read back one JSON MCP configuration. */ export const configureJsonClient = ( diff --git a/src/application/SetupHost.ts b/src/application/SetupHost.ts index 0b1c9910b4..b4a8a49446 100644 --- a/src/application/SetupHost.ts +++ b/src/application/SetupHost.ts @@ -3,6 +3,7 @@ import { homedir } from "node:os"; import { resolve } from "node:path"; import { PRODUCT_IDENTITY } from "../identity.js"; +import { npxRegistrationCommand } from "./ClientRegistrationIdentity.js"; import { SUPPORTED_NODE_VERSION_PROSE, supportsNodeVersion, @@ -35,7 +36,7 @@ export const setupRegistrationCommand = ( useNpmRunner: boolean = process.env.npm_command === "exec", ): readonly string[] => useNpmRunner - ? PRODUCT_IDENTITY.mcpCommand.split(" ") + ? npxRegistrationCommand(platform) : platform === "win32" ? [ process.execPath, diff --git a/src/application/investigation/EvidenceLedger.ts b/src/application/investigation/EvidenceLedger.ts index a75760e807..60fcc0ebd4 100644 --- a/src/application/investigation/EvidenceLedger.ts +++ b/src/application/investigation/EvidenceLedger.ts @@ -2,6 +2,7 @@ import canonicalize from "canonicalize"; import { createEvidenceBundle, + describeEvidenceBundleFailure, parseEvidenceBundle, validateResidualUnknownAddition, type EvidenceBundle, @@ -68,6 +69,7 @@ export class EvidenceLedger { return err( new EvidenceIntegrityError("Evidence bundle validation failed", { cause, + userMessage: describeEvidenceBundleFailure(input, cause), }), ); } diff --git a/src/artifacts/ArtifactProvider.ts b/src/artifacts/ArtifactProvider.ts index 4a3d02fc2e..941b75d0f0 100644 --- a/src/artifacts/ArtifactProvider.ts +++ b/src/artifacts/ArtifactProvider.ts @@ -21,7 +21,10 @@ import { type ArtifactAnalysisOperation, } from "../contracts/artifactToolContracts.js"; import type { BinaryTarget } from "../domain/binaryTarget.js"; -import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js"; +import { + AnalysisCapabilityUnavailableError, + AnalysisInputError, +} from "../domain/analysisErrorCore.js"; import { ArtifactOperationError } from "../domain/artifactOperationError.js"; import { type AnalysisError } from "../domain/analysisErrorBase.js"; import type { JsonValue } from "../domain/jsonValue.js"; @@ -116,10 +119,15 @@ class ArtifactClient implements AnalysisClient { parameters.path !== "." && parameters.path !== basename(this.target.path) ) - throw new ArtifactReaderFailure( - "path", - "For an active plist, path must select that archive (omit path or use its basename)", - ); + throw new AnalysisInputError(operation, undefined, [ + { + path: ["path"], + reason: "invalid_value", + message: + "For an active plist, path must select that archive (omit path or use its basename).", + expected: basename(this.target.path), + }, + ]); const bundlePath = standalone ? dirname(this.target.path) : this.target.sourcePath; @@ -352,6 +360,8 @@ const translateFailure = ( cause.details, cause.message, ); + // Caller-selection failures are already typed; keep their correction details. + if (cause instanceof AnalysisInputError) return cause; return new ArtifactOperationError(operation, "io"); }; diff --git a/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts b/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts index ca0c036e6b..cda0cb5fcd 100644 --- a/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts +++ b/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts @@ -1,6 +1,14 @@ +import { mkdir, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + import { describe, expect, it } from "vitest"; -import { collectInterfaceBuilderResourceKeys } from "./AppleAssetCatalogAnalysis.js"; +import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; +import { ArtifactReaderFailure } from "../ArtifactReader.js"; +import { + analyzeAppleAssetCatalogs, + collectInterfaceBuilderResourceKeys, +} from "./AppleAssetCatalogAnalysis.js"; describe("Apple asset catalog application workflow", () => { it("joins only explicit resource-key fields with archive provenance", () => { @@ -40,4 +48,70 @@ describe("Apple asset catalog application workflow", () => { }, ]); }); + + it("reports an app without compiled catalogs as an empty observed inventory", async () => { + const bundle = await createTestTempDirectory("rea-asset-catalog-absent-"); + await mkdir(join(bundle, "Contents", "Resources"), { recursive: true }); + await writeFile(join(bundle, "Contents", "Resources", "icon.icns"), ""); + const result = await analyzeAppleAssetCatalogs({ + bundlePath: bundle, + targetSha256: "a".repeat(64), + runAssetUtil: () => { + throw new Error("assetutil must not run without a catalog"); + }, + }); + expect(result).toMatchObject({ + catalogs: [], + total_records: 0, + records: [], + resource_key_matches: [], + next_offset: null, + truncated: false, + }); + expect(result.limitations).toContain( + "No compiled Assets.car catalog was found among the app bundle's regular files (symlinks are not followed); any resource keys are reported as unmatched.", + ); + }); + + it("does not report absence when the only catalog entry is a symlink", async () => { + const bundle = await createTestTempDirectory("rea-asset-catalog-link-"); + const resources = join(bundle, "Contents", "Resources"); + await mkdir(resources, { recursive: true }); + await writeFile(join(bundle, "outside.car"), "catalog"); + await symlink("../../outside.car", join(resources, "Assets.car")); + const rejected = await analyzeAppleAssetCatalogs({ + bundlePath: bundle, + targetSha256: "a".repeat(64), + runAssetUtil: () => Promise.resolve("[]"), + }).catch((cause: unknown) => cause); + expect(rejected).toBeInstanceOf(ArtifactReaderFailure); + expect(rejected).toMatchObject({ + reason: "path", + message: expect.stringContaining( + "Contents/Resources/Assets.car (symlink)", + ), + }); + }); + + it("discloses skipped catalog entries beside inspected catalogs", async () => { + const bundle = await createTestTempDirectory("rea-asset-catalog-mixed-"); + const resources = join(bundle, "Contents", "Resources"); + await mkdir(join(resources, "Nested"), { recursive: true }); + await writeFile(join(resources, "Assets.car"), "catalog"); + await symlink("../Assets.car", join(resources, "Nested", "Assets.car")); + const result = await analyzeAppleAssetCatalogs({ + bundlePath: bundle, + targetSha256: "a".repeat(64), + runAssetUtil: () => Promise.resolve("[]"), + }); + expect(result.catalogs.map(({ path }) => path)).toEqual([ + "Contents/Resources/Assets.car", + ]); + expect(result.limitations).toContainEqual( + expect.stringContaining("Contents/Resources/Nested/Assets.car (symlink)"), + ); + expect(result.limitations).not.toContainEqual( + expect.stringContaining("No compiled Assets.car catalog"), + ); + }); }); diff --git a/src/artifacts/apple/AppleAssetCatalogAnalysis.ts b/src/artifacts/apple/AppleAssetCatalogAnalysis.ts index 36b88eb134..65de9ab257 100644 --- a/src/artifacts/apple/AppleAssetCatalogAnalysis.ts +++ b/src/artifacts/apple/AppleAssetCatalogAnalysis.ts @@ -48,10 +48,15 @@ export const analyzeAppleAssetCatalogs = async (input: { sha256: string; records: ReturnType; }[] = []; + const uninspected: string[] = []; let totalMetadataBytes = 0; try { for await (const entry of reader.entries(input.signal)) { - if (entry.kind !== "file" || !entry.path.endsWith("Assets.car")) continue; + if (!entry.path.endsWith("Assets.car")) continue; + if (entry.kind !== "file") { + uninspected.push(`${entry.path} (${entry.kind})`); + continue; + } const inspected = await inspectAssetCatalogEntry({ reader, entry, @@ -69,10 +74,12 @@ export const analyzeAppleAssetCatalogs = async (input: { } finally { await reader.close(); } - if (catalogs.length === 0) + // Without any readable catalog, unmatched resource keys would overstate an + // absence that a skipped catalog entry may contradict. + if (catalogs.length === 0 && uninspected.length > 0) throw new ArtifactReaderFailure( - "unavailable", - "No compiled Assets.car catalogs were found in the app bundle", + "path", + `Asset catalog entries are not regular files and were not inspected: ${uninspected.join(", ")}`, ); const interfaceBuilder = await analyzeInterfaceBuilderBundle({ bundlePath: input.bundlePath, @@ -93,6 +100,18 @@ export const analyzeAppleAssetCatalogs = async (input: { ...result, limitations: [ ...result.limitations, + // An app without a compiled catalog is an observed empty inventory, not + // a missing host tool. + ...(catalogs.length === 0 + ? [ + "No compiled Assets.car catalog was found among the app bundle's regular files (symlinks are not followed); any resource keys are reported as unmatched.", + ] + : []), + ...(uninspected.length === 0 + ? [] + : [ + `Asset catalog entries that are not regular files were not inspected, so resource keys reported as unmatched may resolve there: ${uninspected.join(", ")}`, + ]), "Exact resource-key joins use only recognized image or asset key fields on decoded Interface Builder resource objects; other archive resource references are not inferred.", ...(interfaceBuilder.graph.truncated ? [ diff --git a/src/artifacts/apple/KeyedArchiveReader.test.ts b/src/artifacts/apple/KeyedArchiveReader.test.ts index 870dc55e7b..3910eef5cf 100644 --- a/src/artifacts/apple/KeyedArchiveReader.test.ts +++ b/src/artifacts/apple/KeyedArchiveReader.test.ts @@ -125,6 +125,16 @@ describe("inert keyed archive decoding", () => { offset: 0, limit: 1, }), - ).toThrow("does not exist"); + ).toThrow( + expect.objectContaining({ + issues: [ + expect.objectContaining({ + path: ["root"], + message: expect.stringContaining("does not exist"), + expected: ["root", "other"], + }), + ], + }), + ); }); }); diff --git a/src/artifacts/apple/KeyedArchiveReader.ts b/src/artifacts/apple/KeyedArchiveReader.ts index e948619971..fafa70fbe8 100644 --- a/src/artifacts/apple/KeyedArchiveReader.ts +++ b/src/artifacts/apple/KeyedArchiveReader.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { TextDecoder } from "node:util"; import { parseBinary } from "plist"; import { z } from "zod"; +import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; import type { JsonValue } from "../../domain/jsonValue.js"; import { projectPlistValue } from "../../domain/apple/plistValue.js"; import { @@ -25,6 +26,10 @@ export const decodeKeyedArchiveBytes = ( ) => { if (bytes.length > MAX_BYTES) throw new RangeError("Keyed archive exceeds 64 MiB"); + if (bytes.subarray(0, 10).toString("ascii") === "NIBArchive") + throw new TypeError( + "Selected file is a compiled NIBArchive, not a Foundation plist archive; decode it with decode_interface_builder", + ); const binary = bytes.subarray(0, 8).toString("ascii") === "bplist00"; const parsed = binary ? { value: parseBinary(bytes), omittedPrototypeKeys: 0 } @@ -83,18 +88,20 @@ export const inspectBundleKeyedArchive = async (input: { .split("/") .some((part) => part === ".." || part === "." || part.length === 0) ) - throw new ArtifactReaderFailure( - "path", - "Archive path must be a canonical relative bundle path", + throw archivePathError( + "invalid_format", + selected.path === "." + ? "Select a keyed archive path relative to the active app bundle." + : `Archive path must be a canonical relative bundle path without empty, '.', or '..' segments: ${selected.path}`, ); const reader = new DirectoryArtifactReader(input.bundlePath); try { for await (const entry of reader.entries(input.signal)) { if (entry.path !== selected.path) continue; if (entry.kind !== "file") - throw new ArtifactReaderFailure( - "path", - "Archive must be a regular file, not a symlink or directory", + throw archivePathError( + "invalid_value", + `Archive path selects a ${entry.kind}, not a regular file: ${selected.path}`, ); if ((entry.declaredSize ?? 0) > MAX_BYTES) throw new ArtifactReaderFailure( @@ -132,18 +139,28 @@ export const inspectBundleKeyedArchive = async (input: { ...decodeKeyedArchiveBytes(bytes, selected), }); } catch (cause) { + if (cause instanceof AnalysisInputError) throw cause; throw new ArtifactReaderFailure( cause instanceof RangeError ? "limit" : "format", - "Cannot decode selected Foundation keyed archive", + `Cannot decode selected Foundation keyed archive: ${cause instanceof Error ? cause.message : String(cause)}`, { cause }, ); } } - throw new ArtifactReaderFailure( - "path", - `Archive not found in bundle: ${selected.path}`, + throw archivePathError( + "invalid_value", + `No regular file exists at ${selected.path} in the active app bundle.`, ); } finally { await reader.close(); } }; + +/** A caller-selected archive path, not the artifact, failed its constraint. */ +const archivePathError = ( + reason: "invalid_format" | "invalid_value", + message: string, +) => + new AnalysisInputError("inspect_keyed_archive", undefined, [ + { path: ["path"], reason, message }, + ]); diff --git a/src/browser/PlaywrightElectronActiveProvider.ts b/src/browser/PlaywrightElectronActiveProvider.ts index 2ca18b4210..5aa0ef88cb 100644 --- a/src/browser/PlaywrightElectronActiveProvider.ts +++ b/src/browser/PlaywrightElectronActiveProvider.ts @@ -26,6 +26,7 @@ import { type OwnedProcessGroup, type ProcessCleanupResult, type ProcessLineageObservation, + type OwnershipSweepUnverifiedProcess, } from "../process/ProcessOwnership.js"; import { observeOwnedProcessLineage, @@ -238,6 +239,17 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati ), ); if (closeError !== undefined) return err(providerError(closeError)); + if (outcome.ok && cleanup.unverified !== undefined) + outcome = ok({ + ...outcome.value, + limitations: [ + ...outcome.value.limitations, + ...cleanup.unverified.map( + ({ pid, diagnostic }) => + `Ownership of unrelated process ${String(pid)} could not be verified; it was left untouched: ${diagnostic}`, + ), + ], + }); } return outcome; } @@ -280,6 +292,7 @@ const cleanupElectronProcesses = async ( })), ); let signaled = false; + const unverified: OwnershipSweepUnverifiedProcess[] = []; for (const processGroupId of groupIds) { const cleanupOwnership: OwnedProcessGroup = processGroupId === ownership.processGroupId @@ -292,14 +305,38 @@ const cleanupElectronProcesses = async ( const result = await cleanupOwnedProcessGroup(cleanupOwnership); if (!result.cleaned) return result; signaled ||= result.signaled; + unverified.push(...(result.unverified ?? [])); } const remaining = await verifyNoTokenOwnedProcesses( ownership.runId, undefined, ownership.captureBaseline, + { + leaderPid: ownership.leaderPid, + processGroupId: ownership.processGroupId, + ...(ownership.sampledProcessGroupIds === undefined + ? {} + : { sampledProcessGroupIds: ownership.sampledProcessGroupIds }), + }, ); if (!remaining.cleaned) return remaining; - return { cleaned: true, signaled }; + unverified.push(...(remaining.unverified ?? [])); + return { + cleaned: true, + signaled, + ...(unverified.length === 0 + ? {} + : { + unverified: [ + ...new Map( + unverified.map((item) => [ + `${String(item.pid)}:${item.diagnostic}`, + item, + ]), + ).values(), + ], + }), + }; }; const createResult = ( diff --git a/src/browser/assets/LocalWebScriptArtifacts.ts b/src/browser/assets/LocalWebScriptArtifacts.ts index 625246cebf..985756a738 100644 --- a/src/browser/assets/LocalWebScriptArtifacts.ts +++ b/src/browser/assets/LocalWebScriptArtifacts.ts @@ -1,5 +1,5 @@ import { lstat, realpath } from "node:fs/promises"; -import { dirname, isAbsolute, join, relative, sep } from "node:path"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; import { normalizeArtifactPath } from "../../artifacts/ArtifactPaths.js"; @@ -55,7 +55,7 @@ export class LocalWebScriptArtifacts implements WebScriptArtifactPort { "path", `Selected source path changes during normalization: ${selected.content.relative_path}`, ); - const root = dirname(input.manifest_path); + const root = resolve(dirname(input.manifest_path)); const sourcePath = join(root, "files", portable); targetPath = sourcePath; await assertContained(root, sourcePath); diff --git a/src/browser/history/HarCapture.test.ts b/src/browser/history/HarCapture.test.ts index 7e525ad5a6..4c29504a8a 100644 --- a/src/browser/history/HarCapture.test.ts +++ b/src/browser/history/HarCapture.test.ts @@ -161,14 +161,28 @@ it("rejects identical duplicate core fields before schema validation", () => { expect(() => decodeHarCapture(text, [])).toThrow("duplicate"); }); -it("reports an unsupported JSON schema boundary instead of dropping a producer property", () => { +it("preserves prototype-named producer members through the capture boundary", () => { const text = JSON.stringify(historicalHar()).replace( '"cache":{}', '"cache":{},"_extension":{"__proto__":{"preserved":7},"constructor":"ordinary"}', ); - expect(() => decodeHarCapture(text, [])).toThrow( - "JSON schema boundary cannot preserve", - ); + const result = decodeHarCapture(text, []); + const reported = result.records[0]?.reported; + expect(reported).toBeDefined(); + if ( + reported === undefined || + reported === null || + typeof reported !== "object" || + Array.isArray(reported) + ) + throw new Error("No HAR object record was retained"); + const extension = reported["_extension"]; + expect(extension).toEqual({ + ["__proto__"]: { preserved: 7 }, + constructor: "ordinary", + }); + expect(Object.getPrototypeOf(extension)).toBe(Object.prototype); + expect(Reflect.get(Object.prototype, "preserved")).toBeUndefined(); }); it("preserves Unicode, offsets, sizes, duplicate URLs and opaque extensions without inventing bytes", () => { diff --git a/src/browser/history/HarJson.ts b/src/browser/history/HarJson.ts index 93f25e7cdf..2576309324 100644 --- a/src/browser/history/HarJson.ts +++ b/src/browser/history/HarJson.ts @@ -58,11 +58,9 @@ export const parseHarJson = (text: string): unknown => { }); }, onObjectProperty: (key) => { - if (key === "__proto__") - throw new CaptureFormatError( - "unsupported", - "HAR object contains a __proto__ member that the current JSON schema boundary cannot preserve.", - ); + // Prototype-named members are kept as ordinary own properties: the + // members map keys them without prototype setters, and + // Object.fromEntries materializes them as own data properties. const frame = frames.at(-1); if (frame?.kind !== "object") throw new TypeError( diff --git a/src/cli.ts b/src/cli.ts index 38b5ab7595..6f6b84c3f2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,6 +4,7 @@ import { Cli } from "incur"; import { createLogger, parseLogLevel } from "./logger.js"; import { PRODUCT_IDENTITY } from "./identity.js"; +import { npxRegistrationCommand } from "./application/ClientRegistrationIdentity.js"; import { registerSetupCommands } from "./cli/setupCommands.js"; import { registerCoreAnalysisCommands } from "./cli/coreAnalysisCommands.js"; import { registerUtilityCommands } from "./cli/utilityCommands.js"; @@ -47,7 +48,7 @@ export const createCli = ( version: PRODUCT_IDENTITY.packageVersion, description: "Reverse engineer anything from your terminal or agent.", mcp: { - command: PRODUCT_IDENTITY.mcpCommand, + command: npxRegistrationCommand().join(" "), instructions: "Ask what software, artifact, protocol, or behavior the user wants to understand, then choose the available investigation capabilities that can produce evidence.", }, diff --git a/src/contracts/webNetworkCaptureToolContracts.ts b/src/contracts/webNetworkCaptureToolContracts.ts index a9e45d606d..6acfa2241e 100644 --- a/src/contracts/webNetworkCaptureToolContracts.ts +++ b/src/contracts/webNetworkCaptureToolContracts.ts @@ -12,7 +12,7 @@ export const WEB_NETWORK_CAPTURE_TOOL_CONTRACTS = [ name: "inspect_web_network_capture", ...toolContractMetadata("inspect_web_network_capture"), description: - "Inspect retained local HAR 1.2 or native mitmproxy capture records without contacting recorded URLs. Returns original ordinals, pointers/byte ranges, preserved producer fields, precise numeric representations and exact exposed binary bytes inline. Excludes known transport credentials and explicitly marked values. Decodes every record before optional ordinal selection; malformed or oversized captures return no partial success. Duplicate HAR members are invalid; prototype-named __proto__ members are unsupported by the current shared JSON boundary. Uses pinned upstream parsers, owned temporary files and bounded offline processes. Native mitmproxy requires caller-supplied mitmdump 12.2.3 on Linux. Browser transaction IDs, runtime attribution and original bytes of HAR Unicode text remain unknown.", + "Inspect retained local HAR 1.2 or native mitmproxy capture records without contacting recorded URLs. Returns original ordinals, pointers/byte ranges, preserved producer fields, precise numeric representations and exact exposed binary bytes inline. Excludes known transport credentials and explicitly marked values. Decodes every record before optional ordinal selection; malformed or oversized captures return no partial success. Duplicate HAR members are invalid; prototype-named members such as __proto__ are preserved as ordinary own JSON members without prototype mutation. Uses pinned upstream parsers, owned temporary files and bounded offline processes. Native mitmproxy requires caller-supplied mitmdump 12.2.3 on Linux. Browser transaction IDs, runtime attribution and original bytes of HAR Unicode text remain unknown.", kind: "browser-provider", inputSchema: inspectWebNetworkCaptureInputSchema, outputSchema: evidenceResultOf(webNetworkCaptureSchema), diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index 48a1f0860a..76cd82b8f7 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -162,8 +162,7 @@ export const analysisErrorUserMessage = (error: AnalysisError): string => { : `Evidence ${error.evidenceId} does not match the requested reference (${error.reason}). Check the expected and actual identity in the diagnostic details.`; if (error instanceof EvidenceIntegrityError) return "Evidence is invalid or has changed. Recreate or re-import it, then try again."; - if (error instanceof EvidenceFileError) - return evidenceFileMessage(error.reason); + if (error instanceof EvidenceFileError) return evidenceFileMessage(error); if (error instanceof UnknownRegistryError && error.reason === "not-found") return "The requested residual unknown does not exist in this session. Check the unknown_id and try again."; if (error instanceof UnknownRegistryError) @@ -258,7 +257,14 @@ const artifactMessage = (reason: ArtifactOperationError["reason"]): string => { return "Artifact could not be read or written. Check file access and try again."; }; -const evidenceFileMessage = (reason: EvidenceFileError["reason"]): string => { +const evidenceFileMessage = ({ + operation, + reason, +}: EvidenceFileError): string => { + if (reason === "missing") + return operation === "read" + ? "Evidence file does not exist at the selected path. Check the path and try again." + : "Evidence output directory does not exist. Choose an existing directory and try again."; if (reason === "not-file") return "Evidence path does not point to a regular file. Choose a file and try again."; if (reason === "exists") diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index 6ba8cfac37..bd8e7e9c9b 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -259,7 +259,11 @@ const artifactStateErrorDetails = ( }; if (error instanceof UnknownRegistryError) return { reason: error.reason }; if (error instanceof EvidenceFileError) - return { operation: error.operation, reason: error.reason }; + return { + operation: error.operation, + reason: error.reason, + ...(error.path === undefined ? {} : { path: error.path }), + }; return undefined; }; diff --git a/src/domain/apple/keyedArchive.ts b/src/domain/apple/keyedArchive.ts index 2fc6a792f9..0f945b58b3 100644 --- a/src/domain/apple/keyedArchive.ts +++ b/src/domain/apple/keyedArchive.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { AnalysisInputError } from "../analysisErrorCore.js"; import { jsonValueSchema, type JsonValue } from "../jsonValue.js"; /** Select one archive within the active bundle and optionally one named root. */ @@ -85,7 +86,14 @@ export const projectKeyedArchive = ( if (top === undefined || Object.keys(top).length === 0) throw new TypeError("Keyed archive has no $top roots"); if (selection.root !== undefined && !Object.hasOwn(top, selection.root)) - throw new TypeError(`Archive root does not exist: ${selection.root}`); + throw new AnalysisInputError("inspect_keyed_archive", undefined, [ + { + path: ["root"], + reason: "invalid_value", + message: `Archive root does not exist: ${selection.root}. Select one of the archive's $top roots or omit root.`, + expected: Object.keys(top), + }, + ]); const roots = selection.root === undefined ? top diff --git a/src/domain/evidence.test.ts b/src/domain/evidence.test.ts index c5c20a5215..95dabcc1a9 100644 --- a/src/domain/evidence.test.ts +++ b/src/domain/evidence.test.ts @@ -44,13 +44,27 @@ it("snapshots caller-owned payloads before deriving their identity", () => { }); describe("analysis evidence identity", () => { - it("normalizes prototype-named parameter keys", () => { + it("preserves prototype-named parameter keys and their semantic identity", () => { const evidence = createEvidence(TARGET, PROVIDER, { operation: "health", parameters: Object.fromEntries([["__proto__", false]]), result: true, }); + expect(evidence.parameters).toEqual({ ["__proto__"]: false }); + expect(Object.hasOwn(evidence.parameters, "__proto__")).toBe(true); expect(parseEvidence(evidence)).toEqual(evidence); + const stripped = createEvidence(TARGET, PROVIDER, { + operation: "health", + parameters: {}, + result: true, + }); + expect(stripped.evidence_id).not.toBe(evidence.evidence_id); + expect(() => + parseEvidence({ + ...evidence, + parameters: { ["__proto__"]: true }, + }), + ).toThrow(/semantic identifier/u); }); it.prop([ @@ -280,3 +294,27 @@ describe("evidence parameter depth bound", () => { ).toBe(true); }); }); + +it("preserves prototype-named raw and normalized results with aligned identity", () => { + const result = { ["__proto__"]: { preserved: 7 }, constructor: "ordinary" }; + const evidence = createEvidence(TARGET, PROVIDER, { + operation: "health", + parameters: {}, + result, + rawResult: result, + }); + const parsed = parseEvidence(evidence); + expect(parsed.normalized_result).toEqual(result); + expect(parsed.raw_result).toEqual(result); + expect(Object.getPrototypeOf(parsed.normalized_result)).toBe( + Object.prototype, + ); + expect(Reflect.get(Object.prototype, "preserved")).toBeUndefined(); + const stripped = createEvidence(TARGET, PROVIDER, { + operation: "health", + parameters: {}, + result: { constructor: "ordinary" }, + rawResult: { constructor: "ordinary" }, + }); + expect(stripped.evidence_id).not.toBe(evidence.evidence_id); +}); diff --git a/src/domain/evidence.ts b/src/domain/evidence.ts index 844c9e4882..a634d0c3b1 100644 --- a/src/domain/evidence.ts +++ b/src/domain/evidence.ts @@ -7,7 +7,11 @@ import { type AnalysisProfileCommitment, } from "./analysisProfile.js"; import type { BinaryTarget } from "./binaryTarget.js"; -import { jsonValueSchema, type JsonValue } from "./jsonValue.js"; +import { + jsonObjectSchema, + jsonValueSchema, + type JsonValue, +} from "./jsonValue.js"; import { digestSchema } from "./../domain/digests.js"; import { prefixedDigestSchema } from "./../domain/digests.js"; @@ -89,7 +93,7 @@ const evidenceBaseSchema = z provider: providerSchema, predicate_type: z.string().min(1), operation: z.string().min(1), - parameters: z.record(z.string(), jsonValueSchema), + parameters: jsonObjectSchema, raw_result: jsonValueSchema.nullable(), normalized_result: jsonValueSchema, confidence: z.enum(["observed", "derived", "inferred"]), diff --git a/src/domain/evidenceBundle.ts b/src/domain/evidenceBundle.ts index 1f92146ea6..807781b667 100644 --- a/src/domain/evidenceBundle.ts +++ b/src/domain/evidenceBundle.ts @@ -182,6 +182,34 @@ const referencedEvidenceIds = (unknown: ResidualUnknown): readonly string[] => [ ...(unknown.resolution?.evidence_ids ?? []), ]; +/** + * Name the bundle constraint that failed and how to recover. A single + * Evidence record, such as one command result, is not a bundle. + */ +export const describeEvidenceBundleFailure = ( + input: unknown, + cause: unknown, +): string => { + const record = evidenceRecordSchema.safeParse(input); + if (record.success) + return `Expected an Evidence bundle with records and manifest arrays, but this JSON is one Evidence record (${record.data.evidence_id}). Export a session bundle with the MCP export_evidence_bundle tool and supply that file.`; + return cause instanceof z.ZodError + ? `Evidence bundle does not match the bundle schema ${describeValidationFailure(cause)}. Supply an unmodified bundle written by export_evidence_bundle or rea evidence-export.` + : `Evidence bundle validation failed: ${describeValidationFailure(cause)}. Recreate or re-export the bundle, then try again.`; +}; + +/** Locate the first schema issue, or keep a thrown constraint message. */ +export const describeValidationFailure = (cause: unknown): string => { + if (!(cause instanceof z.ZodError)) + return cause instanceof Error ? cause.message : String(cause); + const [first, ...rest] = cause.issues; + const location = + first === undefined || first.path.length === 0 + ? "the document root" + : first.path.map(String).join("."); + return `at ${location}: ${first?.message ?? "invalid value"}${rest.length === 0 ? "" : ` (${String(rest.length)} more issues)`}`; +}; + /** Parse records, verify semantic IDs, and reject inconsistent manifests. */ export const parseEvidenceBundle = (input: unknown): EvidenceBundle => { const parsed = evidenceBundleSchema.parse(input); diff --git a/src/domain/evidenceErrors.ts b/src/domain/evidenceErrors.ts index 2e84cd83d4..f6e0c3237f 100644 --- a/src/domain/evidenceErrors.ts +++ b/src/domain/evidenceErrors.ts @@ -1,8 +1,20 @@ -import { AnalysisError } from "./analysisErrorBase.js"; +import { + AnalysisError, + type AnalysisErrorOptions, +} from "./analysisErrorBase.js"; /** Evidence identity, schema, or bundle manifests failed integrity checks. */ export class EvidenceIntegrityError extends AnalysisError { readonly _tag = "EvidenceIntegrityError"; + override readonly userMessage: string | undefined; + + constructor( + message: string, + options?: AnalysisErrorOptions & { readonly userMessage?: string }, + ) { + super(message, options); + this.userMessage = options?.userMessage; + } } /** A session Evidence reference is missing or has the wrong semantic identity. */ @@ -25,11 +37,15 @@ export class EvidenceReferenceError extends EvidenceIntegrityError { export class EvidenceFileError extends AnalysisError { readonly _tag = "EvidenceFileError"; + /** Caller-selected path that failed, resolved against the working directory. */ + readonly path: string | undefined; + constructor( readonly operation: "read" | "write", - readonly reason: "not-file" | "exists" | "invalid-json" | "io", - options?: ErrorOptions, + readonly reason: "not-file" | "exists" | "invalid-json" | "missing" | "io", + options?: ErrorOptions & { readonly path?: string }, ) { super(`Evidence bundle ${operation} failed: ${reason}`, options); + this.path = options?.path; } } diff --git a/src/domain/javascript/javascriptSemanticQuerySchemas.ts b/src/domain/javascript/javascriptSemanticQuerySchemas.ts index 0e9faf4f6a..0cd94e2806 100644 --- a/src/domain/javascript/javascriptSemanticQuerySchemas.ts +++ b/src/domain/javascript/javascriptSemanticQuerySchemas.ts @@ -12,16 +12,21 @@ import { digestSchema } from "../digests.js"; import { prefixedDigestSchema } from "../digests.js"; const semanticNodeIdSchema = prefixedDigestSchema("jsrg_node"); +const literalValueSchema = jsonValueSchema.refine( + (value) => + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean", + "Literal seeds accept only JSON primitive values", +); +// Zod derivations drop the shared schema's JSON Schema projection override; +// re-apply it so MCP catalog generation keeps projecting `{}`. +literalValueSchema._zod.toJSONSchema = () => ({}); + const literalSeedSchema = z.strictObject({ kind: z.literal("literal"), - value: jsonValueSchema.refine( - (value) => - value === null || - typeof value === "string" || - typeof value === "number" || - typeof value === "boolean", - "Literal seeds accept only JSON primitive values", - ), + value: literalValueSchema, }); /** Authenticated starting points accepted by semantic tracing. */ diff --git a/src/domain/jsonValue.test.ts b/src/domain/jsonValue.test.ts index 48f23a15d5..9b59312338 100644 --- a/src/domain/jsonValue.test.ts +++ b/src/domain/jsonValue.test.ts @@ -99,3 +99,34 @@ describe("jsonValueSchema depth bound", () => { ).toBe(true); }); }); + +it("preserves prototype-named members without prototype mutation", () => { + const input: unknown = JSON.parse( + '{"__proto__":{"preserved":7},"constructor":"ordinary","prototype":[1],"nested":{"\\u005f\\u005fproto\\u005f\\u005f":"escaped"},"list":[{"__proto__":null}]}', + ); + const parsed = jsonObjectSchema.parse(input); + expect(parsed).toEqual(input); + expect(Object.getPrototypeOf(parsed)).toBe(Object.prototype); + expect(Object.hasOwn(parsed, "__proto__")).toBe(true); + expect(parsed["__proto__"]).toEqual({ preserved: 7 }); + expect(parsed["constructor"]).toBe("ordinary"); + expect(parsed["prototype"]).toEqual([1]); + expect(parsed["nested"]).toEqual({ ["__proto__"]: "escaped" }); + expect(parsed["list"]).toEqual([{ ["__proto__"]: null }]); + expect(Object.getPrototypeOf(parsed["nested"])).toBe(Object.prototype); + expect(Reflect.get(Object.prototype, "preserved")).toBeUndefined(); + expect(JSON.parse(JSON.stringify(parsed))).toEqual(input); +}); + +it.each([undefined, Number.NaN, Infinity, 1n, () => 0, new Date(0)])( + "rejects non-JSON values in prototype-named members (%s)", + (value) => { + for (const input of [ + { ["__proto__"]: value }, + { nested: [{ ["__proto__"]: value }] }, + ]) { + expect(jsonValueSchema.safeParse(input).success).toBe(false); + expect(jsonObjectSchema.safeParse(input).success).toBe(false); + } + }, +); diff --git a/src/domain/jsonValue.ts b/src/domain/jsonValue.ts index ccfb3238f5..7b6f6f649e 100644 --- a/src/domain/jsonValue.ts +++ b/src/domain/jsonValue.ts @@ -1,67 +1,87 @@ import { z } from "zod"; -/** - * Maximum nesting depth accepted for JSON values. - * - * Sits well below the point where the recursive `z.json()` core overflows its - * stack (measured between 2000 and 3000 on Node 24) while leaving room for - * legitimately deep target-derived trees such as compiled NIB view hierarchies. - */ +/** Maximum JSON nesting accepted before recursive result cloning. */ export const MAX_JSON_DEPTH = 1000; +/** JSON-safe value shared by domain, application, and adapter boundaries. */ +export type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue }; + const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); +const recordShape = z.record(z.string(), z.unknown()); -const exceedsMaxDepth = (root: unknown): boolean => { - // Depth counts edges from the root. +const jsonValidationIssue = (root: unknown): string | undefined => { const pending: { value: unknown; depth: number }[] = [ { value: root, depth: 0 }, ]; while (pending.length > 0) { const item = pending.pop(); if (item === undefined) break; - if (item.depth > MAX_JSON_DEPTH) return true; + if (item.depth > MAX_JSON_DEPTH) + return `JSON value exceeds maximum nesting depth of ${MAX_JSON_DEPTH}`; const value = item.value; - if (Array.isArray(value)) { + if ( + value === null || + typeof value === "string" || + typeof value === "boolean" + ) + continue; + if (typeof value === "number") { + if (!Number.isFinite(value)) return "JSON numbers must be finite"; + } else if (Array.isArray(value)) { for (const child of value) pending.push({ value: child, depth: item.depth + 1 }); - } else if (isRecord(value)) { - for (const key of Object.keys(value)) - pending.push({ value: value[key], depth: item.depth + 1 }); - } + } else if (isRecord(value) && recordShape.safeParse(value).success) { + // Inspect every own member, including keys Zod's record parser skips. + for (const child of Object.values(value)) + pending.push({ value: child, depth: item.depth + 1 }); + } else return "Invalid JSON value"; } - return false; + return undefined; }; -/** - * JSON-safe value shared by domain, application, and adapter boundaries. - * - * An iterative depth guard runs before the recursive `z.json()` core so - * hostile nested payloads surface as validation issues instead of RangeErrors. - * The guard only refuses over-deep values; ordinary JSON parsing semantics - * (including which members `z.json()` keeps) are unchanged. - */ -const jsonCore = z.json(); -// Every JSON value satisfies `{}`. Advertise that instead of Zod's -// self-referential `$defs` projection, which MCP clients and model APIs that -// reject recursive JSON Schemas refuse. Runtime parsing still admits only JSON. -// The override sits on the core as well as on the pipe because the standard -// JSON Schema projection walks into pipe stages without consulting the outer -// node, while the public toJSONSchema projection stops at the pipe. -jsonCore._zod.toJSONSchema = () => ({}); +const preserveJsonValue = (value: JsonValue): JsonValue => { + if (Array.isArray(value)) return value.map(preserveJsonValue); + if (value !== null && typeof value === "object") + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [ + key, + preserveJsonValue(child), + ]), + ); + return value; +}; + +const validatedJsonValue = z.custom( + (value) => jsonValidationIssue(value) === undefined, + { + error: (issue) => jsonValidationIssue(issue.input) ?? "Invalid JSON value", + }, +); +// Keep both stages nonrecursive in public and Standard Schema projections. +validatedJsonValue._zod.toJSONSchema = () => ({}); + +/** Parse bounded JSON and retain all own data members without prototype setters. */ export const jsonValueSchema = z .unknown() - .superRefine((value, context) => { - if (exceedsMaxDepth(value)) - context.addIssue({ - code: "custom", - message: `JSON value exceeds maximum nesting depth of ${MAX_JSON_DEPTH}`, - }); - }) - .pipe(jsonCore); + .nonoptional() + .pipe(validatedJsonValue) + .transform(preserveJsonValue); jsonValueSchema._zod.toJSONSchema = () => ({}); -/** JSON object boundary for caller-visible parameter maps. */ -export const jsonObjectSchema = z.record(z.string(), jsonValueSchema); +const objectValidation = z.custom>( + isRecord, + "Expected JSON object", +); +const objectProjection = () => ({ type: "object", additionalProperties: {} }); +objectValidation._zod.toJSONSchema = objectProjection; -export type JsonValue = z.infer; +/** JSON object boundary for caller-visible parameter maps. */ +export const jsonObjectSchema = jsonValueSchema.pipe(objectValidation); +jsonObjectSchema._zod.toJSONSchema = objectProjection; diff --git a/src/domain/process/processCapture.ts b/src/domain/process/processCapture.ts index 7c41ccb168..283706b125 100644 --- a/src/domain/process/processCapture.ts +++ b/src/domain/process/processCapture.ts @@ -223,6 +223,10 @@ export interface UnverifiedProcessCapture { readonly cleanup: { readonly owned_process_group: "verified"; readonly temporary_root: "removed"; + /** Unrelated processes whose ownership token the host could not expose. */ + readonly unverified_processes?: + | readonly UnverifiedCleanupProcess[] + | undefined; }; } @@ -232,10 +236,19 @@ const fileStateShape = { size: z.number().int().nonnegative(), }; +/** An unrelated process left untouched because its ownership is unknown. */ +export interface UnverifiedCleanupProcess { + readonly pid: number; + readonly reason: string; +} + /** A resource cleanup result retained when observations cannot be verified. */ export interface ProcessCaptureResourceCleanup { readonly state: "cleaned" | "failed" | "unverified" | "not_required"; readonly reason: string | null; + readonly unverified_processes?: + | readonly UnverifiedCleanupProcess[] + | undefined; } export interface ProcessCaptureCleanupReport { @@ -348,6 +361,10 @@ const processSettlementSchema = z.discriminatedUnion("state", [ }), ]); +const unverifiedCleanupProcessesSchema = z.array( + z.strictObject({ pid: z.number().int().positive(), reason: z.string() }), +); + const processCaptureShapeSchema = z.strictObject({ manifest: z.strictObject({ rea_version: z.string().min(1), @@ -478,12 +495,14 @@ const processCaptureShapeSchema = z.strictObject({ cleanup: z.object({ owned_process_group: z.literal("verified"), temporary_root: z.literal("removed"), + unverified_processes: unverifiedCleanupProcessesSchema.optional(), }), }); const processCleanupResourceSchema = z.strictObject({ state: z.enum(["cleaned", "failed", "unverified", "not_required"]), reason: z.string().nullable(), + unverified_processes: unverifiedCleanupProcessesSchema.optional(), }); const partialObservationFieldSchema = ( diff --git a/src/native/CodesignVerification.ts b/src/native/CodesignVerification.ts index 6bff2dc512..53ca3d8f78 100644 --- a/src/native/CodesignVerification.ts +++ b/src/native/CodesignVerification.ts @@ -100,6 +100,7 @@ export const signatureVerification = ( capture: NativeCommandCapture, path: string, unsigned: boolean, + progressCompleteness: "complete" | "ambiguous" = "complete", ): NonNullable => { const lines = `${capture.stderr}\n${capture.stdout}` .split("\n") @@ -109,11 +110,10 @@ export const signatureVerification = ( ); return { path, - status: verificationStatus( - capture, - codesignReasons(capture, path), - unsigned, - ), + status: + progressCompleteness === "ambiguous" && capture.exitCode !== 0 + ? "unknown" + : verificationStatus(capture, codesignReasons(capture, path), unsigned), exit_code: capture.exitCode, diagnostics, prepared_nested_code: [ diff --git a/src/native/NativeNestedCode.test.ts b/src/native/NativeNestedCode.test.ts index 967edec297..90525f9c85 100644 --- a/src/native/NativeNestedCode.test.ts +++ b/src/native/NativeNestedCode.test.ts @@ -1,11 +1,11 @@ import { expect, it } from "vitest"; -import { unconfirmedNestedCode } from "./NativeNestedCode.js"; +import { inspectNestedCodePaths } from "./NativeNestedCode.js"; it("observes cancellation between nested filesystem lookups", async () => { const controller = new AbortController(); const paths: string[] = []; await expect( - unconfirmedNestedCode( + inspectNestedCodePaths( { validated_nested_code: ["/app/first", "/app/second"], }, @@ -23,7 +23,7 @@ it("observes cancellation between nested filesystem lookups", async () => { }); it("checks prepared-only fragments with the same completeness rules", async () => { - const warnings = await unconfirmedNestedCode( + const warnings = await inspectNestedCodePaths( { prepared_nested_code: ["/app/odd"], validated_nested_code: [] }, undefined, { @@ -33,9 +33,46 @@ it("checks prepared-only fragments with the same completeness rules", async () = names: async () => [], }, ); - expect(warnings).toContainEqual( + expect(warnings.limitations).toContainEqual( expect.stringContaining( 'prepared nested code at "/app/odd", which does not exist', ), ); }); + +it.each(["EIO", "ESTALE"])( + "keeps observed records when path lookup fails with %s", + async (code) => { + const result = await inspectNestedCodePaths( + { + prepared_nested_code: ["/app/first"], + validated_nested_code: ["/app/second"], + }, + undefined, + { + lookup: async () => { + throw Object.assign(new Error("lookup failed"), { code }); + }, + names: async () => [], + }, + ); + expect(result.limitations).toHaveLength(2); + expect(result.limitations.every((message) => message.includes(code))).toBe( + true, + ); + expect(result.progressAmbiguous).toBe(false); + }, +); +it("detects newline ambiguity even when the reported first fragment does not exist", async () => { + const result = await inspectNestedCodePaths( + { prepared_nested_code: ["/app/odd"], validated_nested_code: [] }, + undefined, + { + lookup: async () => { + throw Object.assign(new Error("missing"), { code: "ENOENT" }); + }, + names: async () => ["odd\n/app: invalid signature"], + }, + ); + expect(result.progressAmbiguous).toBe(true); +}); diff --git a/src/native/NativeNestedCode.ts b/src/native/NativeNestedCode.ts index 04560e4344..6aed612b2b 100644 --- a/src/native/NativeNestedCode.ts +++ b/src/native/NativeNestedCode.ts @@ -1,5 +1,5 @@ import { lstat, readdir } from "node:fs/promises"; -import { dirname } from "node:path"; +import { dirname, basename } from "node:path"; /** Filesystem seam for path-completeness checks and cancellation regressions. */ export interface NativeNestedPathView { @@ -17,18 +17,25 @@ const FILE_SYSTEM: NativeNestedPathView = { const errorCode = (cause: unknown) => cause instanceof Error && "code" in cause ? cause.code : undefined; -/** Preserve reported paths, but qualify missing, denied or newline-ambiguous records. */ -export const unconfirmedNestedCode = async ( +/** Completeness of newline-delimited progress records, distinct from verification. */ +export interface NestedCodePathInspection { + readonly limitations: readonly string[]; + readonly progressAmbiguous: boolean; +} + +/** Preserve reported paths and operational errors, and identify ambiguous progress. */ +export const inspectNestedCodePaths = async ( verification: { readonly validated_nested_code: readonly string[]; readonly prepared_nested_code?: readonly string[]; }, signal?: AbortSignal, view: NativeNestedPathView = FILE_SYSTEM, -): Promise => { +): Promise => { signal?.throwIfAborted(); const limitations: string[] = []; - const directories = new Map>(); + const directories = new Map>>(); + let progressAmbiguous = false; const records = new Map(); for (const path of verification.prepared_nested_code ?? []) records.set(path, "prepared"); @@ -47,11 +54,14 @@ export const unconfirmedNestedCode = async ( ); continue; } - if (code !== "ENOENT" && code !== "ENOTDIR") throw cause; - limitations.push( - `codesign reported ${record} nested code at ${JSON.stringify(path)}, which does not exist; a nested path probably contains a newline, so structured nested-code records and diagnostics may hold fragments of it.`, - ); - continue; + if (code === "ENOENT" || code === "ENOTDIR") + limitations.push( + `codesign reported ${record} nested code at ${JSON.stringify(path)}, which does not exist; structured nested-code records may hold fragments of a path containing a newline.`, + ); + else + limitations.push( + `Could not confirm completeness of codesign nested path ${JSON.stringify(path)}: ${cause instanceof Error ? cause.message : String(cause)} (${String(code ?? "unknown")}); the reported path may be a fragment.`, + ); } signal?.throwIfAborted(); const parent = dirname(path); @@ -59,24 +69,33 @@ export const unconfirmedNestedCode = async ( if (names === undefined) { names = view .names(parent) - .then((entries) => entries.some((name) => name.includes("\n"))); + .then( + (entries) => + new Set( + entries + .filter((name) => name.includes("\n")) + .map((name) => name.slice(0, name.indexOf("\n"))), + ), + ); directories.set(parent, names); } try { - const ambiguous = await names; + const prefixes = await names; + const ambiguous = prefixes.has(basename(path)); signal?.throwIfAborted(); - if (ambiguous) + if (ambiguous) { + progressAmbiguous = true; limitations.push( - `codesign reported ${record} nested code at ${JSON.stringify(path)}, which exists but could be the first line of a nested path containing a newline; structured nested-code records are ambiguous and should not be trusted as complete.`, + `codesign reported ${record} nested code at ${JSON.stringify(path)}, which could be the first line of a nested path containing a newline; structured nested-code records are ambiguous and should not be trusted as complete.`, ); + } } catch (cause: unknown) { signal?.throwIfAborted(); - if (!(cause instanceof Error) || !("code" in cause)) throw cause; limitations.push( - `Could not confirm completeness of codesign nested path ${JSON.stringify(path)} from parent directory ${JSON.stringify(parent)}: ${cause.message}`, + `Could not confirm completeness of codesign nested path ${JSON.stringify(path)} from parent directory ${JSON.stringify(parent)}: ${cause instanceof Error ? cause.message : String(cause)}`, ); } } signal?.throwIfAborted(); - return limitations; + return { limitations, progressAmbiguous }; }; diff --git a/src/native/NativeSignatureInspection.ts b/src/native/NativeSignatureInspection.ts index 7b88d91613..cca5d4163a 100644 --- a/src/native/NativeSignatureInspection.ts +++ b/src/native/NativeSignatureInspection.ts @@ -37,7 +37,7 @@ import { signedCodePath, stapledTicket, } from "./NativeSignaturePosture.js"; -import { unconfirmedNestedCode } from "./NativeNestedCode.js"; +import { inspectNestedCodePaths } from "./NativeNestedCode.js"; interface SignatureDisplays { readonly display: NativeCommandCapture; @@ -71,6 +71,15 @@ const signatureDisplays = async ( NATIVE_MACOS_PROVIDER_IDENTITY.id, "inspect_signature", { + diagnostics: { + path, + tool: result.tool, + arguments: [...result.arguments], + exit_code: result.exitCode, + signal: result.signal, + raw_stdout: result.stdout, + raw_stderr: result.stderr, + }, cause: new NativeCommandFailure( "codesign", "nonzero-exit", @@ -127,10 +136,10 @@ export const inspectNativeSignature = async (options: { captures.push(anchor.value); appleOrigin = anchor.value.exitCode === 0; if (!appleOrigin) { - const reason = codesignReasons(anchor.value, code).find( - (line) => line.trim().length > 0, - ); - appleOriginFailure = reason ?? `codesign exited ${anchor.value.exitCode}`; + const reason = codesignReasons(anchor.value, code) + .filter((line) => line.trim().length > 0) + .join("\n"); + appleOriginFailure = reason || `codesign exited ${anchor.value.exitCode}`; } } const slices = await inspectSignatureSlices( @@ -145,16 +154,28 @@ export const inspectNativeSignature = async (options: { slices.value.signed === undefined ? unsigned : !slices.value.signed; // Verification is independent evidence: uncertain display probes must not // erase an explicit signature failure from the verifier. - const verification = signatureVerification( + const reportedVerification = signatureVerification( verified.value, code, aggregateUnsigned, ); + const nestedPaths = await inspectNestedCodePaths( + reportedVerification, + signal, + ); + const verification = nestedPaths.progressAmbiguous + ? signatureVerification( + verified.value, + code, + aggregateUnsigned, + "ambiguous", + ) + : reportedVerification; const limitations = [ ...parsed.limitations, ...SIGNATURE_POSTURE_LIMITATIONS, ...slices.value.limitations, - ...(await unconfirmedNestedCode(verification, signal)), + ...nestedPaths.limitations, ]; if (entitlementValue.omittedPrototypeKeys !== 0) limitations.push( diff --git a/src/native/NotarizationTicket.test.ts b/src/native/NotarizationTicket.test.ts index 56d2c54799..cf1b62c8b6 100644 --- a/src/native/NotarizationTicket.test.ts +++ b/src/native/NotarizationTicket.test.ts @@ -24,3 +24,16 @@ it("rejects empty, arbitrary, truncated and contradictory ticket containers", as ticket.writeUInt32LE(2, 16 + 4 + 8); expect(await inspect(ticket)).toContain("hash records disagree"); }); + +it("requires the complete signature field and validates all padding bytes", async () => { + const bytes = Buffer.from(notarizationTicketFixture()); + expect(await inspect(bytes.subarray(0, bytes.length - 64))).toContain( + "72 bytes", + ); + expect(await inspect(bytes)).toBeNull(); + bytes[bytes.length - 1] = 1; + expect(await inspect(bytes)).toContain("signature padding"); + expect(await inspect(Buffer.concat([bytes, Buffer.alloc(1)]))).toContain( + "72 bytes", + ); +}); diff --git a/src/native/NotarizationTicket.ts b/src/native/NotarizationTicket.ts index 7c6d864955..86e1582173 100644 --- a/src/native/NotarizationTicket.ts +++ b/src/native/NotarizationTicket.ts @@ -6,7 +6,7 @@ export type TicketReadAt = ( /** * Inspect the local s8ch/g8tk ticket framing. Observed in stapled app tickets; - * see bytewitch's NotarizedTicket decoder and apple-platform-rs ticket_lookup. + * see https://www.mothersruin.com/software/Archaeology/reverse/tickets.html. * This does not authenticate certificates, signatures, or notarized cdhashes. */ export const ticketStructureIssue = async ( @@ -38,16 +38,18 @@ export const ticketStructureIssue = async ( const certificates = await derSequenceExtent(read, 16, certificateSize); if (certificates !== certificateSize) return "Ticket certificate block has malformed DER framing"; + const signatureFieldSize = 72; + if (size - signatureOffset !== signatureFieldSize) + return "Ticket signature field is truncated or does not contain exactly 72 bytes"; const signatureSize = await derSequenceExtent( read, signatureOffset, - size - signatureOffset, + signatureFieldSize, ); if (signatureSize === null || signatureSize < 8) return "Ticket signature has malformed DER framing"; - // ECDSA encodings can be padded to 72 bytes; only zero padding is accepted. + // The fixed signature field contains DER followed by zero padding. const paddingSize = size - signatureOffset - signatureSize; - if (paddingSize > 2) return "Ticket has unrecognized trailing bytes"; const padding = await read(signatureOffset + signatureSize, paddingSize); if (padding.byteLength !== paddingSize || padding.some((byte) => byte !== 0)) return "Ticket has malformed signature padding"; diff --git a/src/process/ProcessOwnership.identity.test.ts b/src/process/ProcessOwnership.identity.test.ts index 406a35e331..3d79b281d4 100644 --- a/src/process/ProcessOwnership.identity.test.ts +++ b/src/process/ProcessOwnership.identity.test.ts @@ -99,92 +99,102 @@ describe("capture-local process ownership", () => { expect(signalGroup).toHaveBeenCalledWith(process.processGroupId, "SIGKILL"); }); - it("cleans verified owners before reporting an unrelated opaque process", async () => { - const owned: ProcessTableEntry = { - pid: 905, - parentPid: 1, - processGroupId: 905, - state: "S", - command: "owned-detached-child", - }; - const opaque: ProcessTableEntry = { - pid: 906, - parentPid: 1, - processGroupId: 906, - state: "S", - command: "unrelated-process", - }; - const malformed: ProcessTableEntry = { - ...opaque, - pid: 907, - processGroupId: 907, - command: "malformed-procargs-process", - }; - const sysctlDenied: ProcessTableEntry = { - ...opaque, - pid: 908, - processGroupId: 908, - command: "sysctl-denied-process", - }; - const signalGroup = vi.fn(); - const host: ProcessOwnershipHost = { - listProcesses: () => - Promise.resolve([owned, opaque, malformed, sysctlDenied]), - environment: (pid) => - pid === owned.pid - ? Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }) - : Promise.reject(new Error("permission denied")), - runTokens: (entries) => - Promise.resolve( - new Map( - entries.map(({ pid }) => [ - pid, - pid === owned.pid - ? { state: "readable", runId: "run-token" as const } - : { - state: "unavailable", - reason: - pid === malformed.pid - ? ("malformed_procargs" as const) - : pid === sysctlDenied.pid - ? ("sysctl_failed_1" as const) - : ("environment_unavailable" as const), - }, - ]), + it.skipIf(typeof process.getuid !== "function")( + "cleans verified owners and records unrelated opaque candidates as unverified", + async () => { + const owned: ProcessTableEntry = { + pid: 905, + parentPid: 1, + processGroupId: 905, + state: "S", + command: "owned-detached-child", + }; + const opaque: ProcessTableEntry = { + pid: 906, + parentPid: 1, + processGroupId: 906, + state: "S", + command: "/usr/bin/unrelated-process", + }; + const malformed: ProcessTableEntry = { + ...opaque, + pid: 907, + processGroupId: 907, + uid: process.getuid?.() === 0 ? 1 : 0, + command: "/usr/bin/malformed-procargs-process", + }; + const sysctlDenied: ProcessTableEntry = { + ...opaque, + pid: 908, + processGroupId: 908, + uid: process.getuid?.() === 0 ? 1 : 0, + command: "/usr/bin/sysctl-denied-process", + }; + const signalGroup = vi.fn(); + const host: ProcessOwnershipHost = { + platform: "darwin", + listProcesses: () => + Promise.resolve([owned, opaque, malformed, sysctlDenied]), + environment: (pid) => + pid === owned.pid + ? Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }) + : Promise.reject(new Error("permission denied")), + runTokens: (entries) => + Promise.resolve( + new Map( + entries.map(({ pid }) => [ + pid, + pid === owned.pid + ? { state: "readable", runId: "run-token" as const } + : { + state: "unavailable", + reason: + pid === malformed.pid + ? ("malformed_procargs" as const) + : pid === sysctlDenied.pid + ? ("sysctl_failed_1" as const) + : ("platform_binary_environment_withheld" as const), + }, + ]), + ), ), - ), - signalGroup, - }; + signalGroup, + }; - await expect( - cleanupOwnedProcessGroup( - { - runId: "run-token", - leaderPid: 100, - processGroupId: 100, - sweepTokenOwnedProcesses: true, - }, - host, - ), - ).resolves.toMatchObject({ - cleaned: false, - reason: - "process ownership token could not be read for 3 live process(es): environment_unavailable=1, malformed_procargs=1, sysctl_errno_1=1", - }); - expect(signalGroup).toHaveBeenCalledWith(owned.processGroupId, "SIGKILL"); - expect(signalGroup).not.toHaveBeenCalledWith( - opaque.processGroupId, - "SIGKILL", - ); - expect(signalGroup).not.toHaveBeenCalledWith( - malformed.processGroupId, - "SIGKILL", - ); - expect(signalGroup).not.toHaveBeenCalledWith( - sysctlDenied.processGroupId, - "SIGKILL", - ); - }); + await expect( + cleanupOwnedProcessGroup( + { + runId: "run-token", + leaderPid: 100, + processGroupId: 100, + sweepTokenOwnedProcesses: true, + }, + host, + ), + ).resolves.toMatchObject({ + cleaned: true, + signaled: true, + unverified: [ + { pid: 906, diagnostic: "platform_binary_environment_withheld" }, + { pid: 907, diagnostic: "malformed_procargs" }, + { pid: 908, diagnostic: "sysctl_failed_1" }, + ], + }); + expect(signalGroup).toHaveBeenCalledWith(owned.processGroupId, "SIGKILL"); + expect(signalGroup).not.toHaveBeenCalledWith( + opaque.processGroupId, + "SIGKILL", + ); + expect(signalGroup).not.toHaveBeenCalledWith( + malformed.processGroupId, + "SIGKILL", + ); + expect(signalGroup).not.toHaveBeenCalledWith( + sysctlDenied.processGroupId, + "SIGKILL", + ); + }, + ); }); describe("capture-local process identity baselines", () => { diff --git a/src/process/ProcessOwnership.part2.test.ts b/src/process/ProcessOwnership.part2.test.ts index ed6552bdad..9fed67e43b 100644 --- a/src/process/ProcessOwnership.part2.test.ts +++ b/src/process/ProcessOwnership.part2.test.ts @@ -214,4 +214,38 @@ describe("process environment permission diagnostics", () => { expect(signalGroup).not.toHaveBeenCalled(); }, ); + + it.skipIf(typeof process.getuid !== "function")( + "records a foreign-uid unreadable candidate as unverified without signaling", + async () => { + const foreignUid = process.getuid?.() === 0 ? 1 : 0; + const signalGroup = vi.fn(); + const adapter: ProcessOwnershipHost = { + listProcesses: () => + Promise.resolve([ + { + pid: 900, + parentPid: 1, + processGroupId: 900, + uid: foreignUid, + state: "S", + command: "unverifiable-process", + }, + ]), + environment: () => Promise.reject(new Error("EINVAL: sysctl failed")), + signalGroup, + }; + await expect( + cleanupOwnedProcessGroup( + { ...ownership, sweepTokenOwnedProcesses: true }, + adapter, + ), + ).resolves.toMatchObject({ + cleaned: true, + signaled: false, + unverified: [{ pid: 900, diagnostic: "EINVAL: sysctl failed" }], + }); + expect(signalGroup).not.toHaveBeenCalled(); + }, + ); }); diff --git a/src/process/ProcessOwnership.test.ts b/src/process/ProcessOwnership.test.ts index 815d9188c9..a2a39f89af 100644 --- a/src/process/ProcessOwnership.test.ts +++ b/src/process/ProcessOwnership.test.ts @@ -67,7 +67,7 @@ describe("owned process-group cleanup discovery", () => { }); describe("opaque process neighbors during cleanup", () => { - it("cleans verified run-owned groups while reporting an opaque neighbor", async () => { + it("cleans verified run-owned groups and records a withheld unrelated neighbor", async () => { const processes = [ { pid: 100, parentPid: 1, processGroupId: 100, command: "capture" }, { @@ -76,6 +76,71 @@ describe("opaque process neighbors during cleanup", () => { processGroupId: 200, command: "detached-child", }, + { + pid: 900, + parentPid: 1, + processGroupId: 900, + command: "/usr/bin/opaque-neighbor", + }, + ].map((process) => ({ ...process, state: "S" })); + const signalGroup = vi.fn(); + const identities = new Map( + processes.map(({ pid }) => [ + pid, + { state: "readable" as const, identity: `start-${String(pid)}` }, + ]), + ); + const adapter: ProcessOwnershipHost = { + platform: "darwin", + listProcesses: () => Promise.resolve(processes), + environment: (pid) => + Promise.resolve({ + REA_PROCESS_RUN_ID: pid === 900 ? "unowned" : "run-token", + }), + processIdentities: () => Promise.resolve(identities), + runTokens: (members) => + Promise.resolve( + new Map( + members.map(({ pid }) => [ + pid, + pid === 900 + ? { + state: "unavailable" as const, + reason: "platform_binary_environment_withheld", + } + : { state: "readable" as const, runId: "run-token" }, + ]), + ), + ), + signalGroup, + }; + + const result = await cleanupOwnedProcessGroup( + { + ...ownership, + sweepTokenOwnedProcesses: true, + captureBaseline: [], + }, + adapter, + ); + + expect(result).toMatchObject({ + cleaned: true, + signaled: true, + unverified: [ + { pid: 900, diagnostic: "platform_binary_environment_withheld" }, + ], + }); + expect(signalGroup.mock.calls).toEqual([ + [100, "SIGKILL"], + [200, "SIGKILL"], + ]); + expect(signalGroup).not.toHaveBeenCalledWith(900, "SIGKILL"); + }); + + it("fails closed for an unrelated unreadable candidate the OS should expose", async () => { + const processes = [ + { pid: 100, parentPid: 1, processGroupId: 100, command: "capture" }, { pid: 900, parentPid: 1, @@ -91,6 +156,7 @@ describe("opaque process neighbors during cleanup", () => { ]), ); const adapter: ProcessOwnershipHost = { + platform: "darwin", listProcesses: () => Promise.resolve(processes), environment: (pid) => Promise.resolve({ @@ -127,14 +193,83 @@ describe("opaque process neighbors during cleanup", () => { cleaned: false, reason: expect.stringContaining("environment_unavailable=1"), }); - expect(signalGroup.mock.calls).toEqual([ - [100, "SIGKILL"], - [200, "SIGKILL"], - ]); + expect(signalGroup.mock.calls).toEqual([[100, "SIGKILL"]]); expect(signalGroup).not.toHaveBeenCalledWith(900, "SIGKILL"); }); }); +describe("related sweep candidates during cleanup", () => { + it.each(["environment_unavailable", "platform_binary_environment_withheld"])( + "fails closed for a related candidate with %s", + async (diagnostic) => { + const processes = [ + { + pid: 100, + parentPid: 1, + processGroupId: 100, + state: "S", + command: "capture", + }, + { + pid: 101, + parentPid: 100, + processGroupId: 100, + state: "S", + uid: process.getuid?.() === 0 ? 1 : 0, + command: "ready-hang", + }, + ]; + const signalGroup = vi.fn(); + const identities = new Map( + processes.map(({ pid }) => [ + pid, + { state: "readable" as const, identity: `start-${String(pid)}` }, + ]), + ); + const adapter: ProcessOwnershipHost = { + listProcesses: () => Promise.resolve(processes), + environment: (pid) => + pid === 100 + ? Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }) + : Promise.reject(new Error("EACCES: permission denied")), + processIdentities: () => Promise.resolve(identities), + runTokens: (members) => + Promise.resolve( + new Map( + members.map(({ pid }) => [ + pid, + pid === 100 + ? { state: "readable" as const, runId: "run-token" } + : { + state: "unavailable" as const, + reason: diagnostic, + }, + ]), + ), + ), + signalGroup, + }; + + const result = await cleanupOwnedProcessGroup( + { + ...ownership, + sweepTokenOwnedProcesses: true, + captureBaseline: [], + }, + adapter, + ); + + expect(result).toMatchObject({ + cleaned: false, + reason: expect.stringContaining( + "process ownership token could not be read", + ), + }); + expect(signalGroup).not.toHaveBeenCalled(); + }, + ); +}); + describe("sampled detached process groups during cleanup", () => { it("signals only token-authenticated sampled groups and reports an unowned sample", async () => { const processes = [ diff --git a/src/process/ProcessOwnership.ts b/src/process/ProcessOwnership.ts index 814009fe39..eff856e6f2 100644 --- a/src/process/ProcessOwnership.ts +++ b/src/process/ProcessOwnership.ts @@ -41,6 +41,8 @@ export interface ProcessTableEntry { readonly pid: number; readonly parentPid: number; readonly processGroupId: number; + /** Effective owner when the process table reports it. */ + readonly uid?: number; readonly state: string; readonly command: string; } @@ -123,9 +125,33 @@ interface ProcessOwnershipValidationFailure { readonly diagnostic?: string; } +/** + * One live process the ownership sweep could not attribute or exonerate. + * Recorded as evidence instead of failing the capture: the process is not + * related to the owned tree, so cleanup cannot act on it and the operating + * system withheld or removed its token (for example, macOS strips the + * environment of Apple platform binaries, and foreign-uid processes return + * EINVAL for KERN_PROCARGS2). + */ +export interface OwnershipSweepUnverifiedProcess { + readonly pid: number; + readonly diagnostic: string; +} + +/** Capture-owned process structures used to classify sweep candidates. */ +export interface OwnershipSweepRelation { + readonly leaderPid: number; + readonly processGroupId: number; + readonly sampledProcessGroupIds?: readonly number[]; +} + /** Cleanup outcome with per-member diagnostics when ownership is uncertain. */ export type ProcessCleanupResult = - | { readonly cleaned: true; readonly signaled: boolean } + | { + readonly cleaned: true; + readonly signaled: boolean; + readonly unverified?: readonly OwnershipSweepUnverifiedProcess[]; + } | { readonly cleaned: false; readonly reason: string; @@ -137,6 +163,7 @@ export type ProcessCleanupResult = | "process-identity-unavailable"; readonly diagnostic?: string; }[]; + readonly unverified?: readonly OwnershipSweepUnverifiedProcess[]; }; /** Token-verified liveness result used by post-root-exit settlement. */ @@ -242,8 +269,12 @@ export const cleanupOwnedProcessGroup = async ( } } if (plan.unresolved.length > 0) - return cleanupValidationFailure(plan.unresolved); - return { cleaned: true, signaled }; + return cleanupValidationFailure(plan.unresolved, plan.unverified); + return { + cleaned: true, + signaled, + ...(plan.unverified.length === 0 ? {} : { unverified: plan.unverified }), + }; }; /** Verify that no live process still carries an owned capture run token. */ @@ -251,6 +282,7 @@ export const verifyNoTokenOwnedProcesses = async ( runId: string, host: ProcessOwnershipHost = systemHost, captureBaseline?: ProcessOwnershipBaseline, + relation?: OwnershipSweepRelation, ): Promise => { const processTable = await readLiveProcessTable(host); if (!processTable.available) @@ -263,11 +295,21 @@ export const verifyNoTokenOwnedProcesses = async ( runId, host, captureBaseline, + relation, ); - if (scan.failures.length > 0) return cleanupValidationFailure(scan.failures); - return scan.owned.length === 0 - ? { cleaned: true, signaled: false } - : { cleaned: false, reason: "token-owned process remained after cleanup" }; + if (scan.failures.length > 0) + return cleanupValidationFailure(scan.failures, scan.unverified); + if (scan.owned.length > 0) + return { + cleaned: false, + reason: "token-owned process remained after cleanup", + ...(scan.unverified.length === 0 ? {} : { unverified: scan.unverified }), + }; + return { + cleaned: true, + signaled: false, + ...(scan.unverified.length === 0 ? {} : { unverified: scan.unverified }), + }; }; const readLiveProcessTable = async ( @@ -293,6 +335,7 @@ interface OwnedCleanupPlan { readonly signalOrder: readonly number[]; readonly tokenOwnedIdentities: ReadonlyMap; readonly unresolved: readonly ProcessOwnershipValidationFailure[]; + readonly unverified: readonly OwnershipSweepUnverifiedProcess[]; } const createOwnedCleanupPlan = async ( @@ -313,8 +356,15 @@ const createOwnedCleanupPlan = async ( ownership.runId, host, ownership.captureBaseline, + { + leaderPid: ownership.leaderPid, + processGroupId: ownership.processGroupId, + ...(sampledProcessGroupIds.size === 0 + ? {} + : { sampledProcessGroupIds: [...sampledProcessGroupIds] }), + }, ) - : { owned: [], failures: [], identities: new Map() }; + : { owned: [], failures: [], unverified: [], identities: new Map() }; const tokenOwnedGroupIds = new Set( tokenOwned.owned.map(({ processGroupId }) => processGroupId), ); @@ -335,8 +385,15 @@ const createOwnedCleanupPlan = async ( signalOrder: [], tokenOwnedIdentities: tokenOwned.identities, unresolved: tokenOwned.failures, + unverified: tokenOwned.unverified, } - : { cleaned: true, signaled: false }; + : { + cleaned: true, + signaled: false, + ...(tokenOwned.unverified.length === 0 + ? {} + : { unverified: tokenOwned.unverified }), + }; let descendants: readonly ProcessTableEntry[] = []; if (launcher !== undefined) { const identityFailure = launcherIdentityFailure(launcher, ownership); @@ -413,7 +470,7 @@ const createOwnedCleanupPlan = async ( signalableGroups.add(processGroupId); } if (ownership.sweepTokenOwnedProcesses !== true && unresolved.length > 0) - return cleanupValidationFailure(unresolved); + return cleanupValidationFailure(unresolved, tokenOwned.unverified); return { signalOrder: [...signalableGroups] .filter((groupId) => processGroupIds.has(groupId)) @@ -426,6 +483,7 @@ const createOwnedCleanupPlan = async ( ), tokenOwnedIdentities: tokenOwned.identities, unresolved, + unverified: tokenOwned.unverified, }; }; @@ -478,7 +536,9 @@ const revalidateOwnedProcessGroup = async ( const cleanupValidationFailure = ( failures: readonly ProcessOwnershipValidationFailure[], + unverified: readonly OwnershipSweepUnverifiedProcess[] = [], ): ProcessCleanupResult => { + const unverifiedEntry = unverified.length === 0 ? {} : { unverified }; const unreadable = failures.filter( ({ reason }) => reason === "environment-unreadable", ); @@ -487,12 +547,14 @@ const cleanupValidationFailure = ( cleaned: false, reason: "process tree contains an unowned or PID-reused process", failures, + ...unverifiedEntry, }; if (failures.some(({ reason }) => reason === "process-identity-unavailable")) return { cleaned: false, reason: "process identity could not be revalidated", failures, + ...unverifiedEntry, }; if (unreadable.length > 0) { const counts = new Map(); @@ -508,17 +570,20 @@ const cleanupValidationFailure = ( cleaned: false, reason: `process ownership token could not be read for ${String(unreadable.length)} live process(es): ${breakdown}`, failures, + ...unverifiedEntry, }; } return { cleaned: false, reason: "process ownership could not be revalidated", failures, + ...unverifiedEntry, }; }; const sanitizedTokenReadFailure = (diagnostic: string | undefined): string => { if (diagnostic === "environment_unavailable") return diagnostic; + if (diagnostic === "platform_binary_environment_withheld") return diagnostic; const environmentErrno = /^(EACCES|EPERM|ENOENT|ESRCH):/u.exec( diagnostic ?? "", ); @@ -630,14 +695,50 @@ const processOwnershipFailures = async ( interface TokenOwnedProcessScan { readonly owned: readonly ProcessTableEntry[]; readonly failures: readonly ProcessOwnershipValidationFailure[]; + readonly unverified: readonly OwnershipSweepUnverifiedProcess[]; readonly identities: ReadonlyMap; } +/** One sweep candidate whose run token could not be read. */ +interface UnreadableTokenCandidate { + readonly process: ProcessTableEntry; + readonly diagnostic: string; +} + +const TOKEN_READ_RETRY_ATTEMPTS = 3; +const TOKEN_READ_RETRY_DELAY_MS = 40; + +/** Darwin reports this when the kernel withholds a platform binary's environment. */ +const PLATFORM_BINARY_ENVIRONMENT_WITHHELD = + "platform_binary_environment_withheld"; + +const currentProcessUid = (): number | undefined => + typeof process.getuid === "function" ? process.getuid() : undefined; + +/** + * Whether the operating system provably withheld this candidate's run token: + * current macOS omits the environment of Apple platform binaries from + * KERN_PROCARGS2 and fails with EINVAL for processes owned by another + * account, and neither class can be token-verified by any caller. Such a + * candidate is recorded as unverified instead of failing an otherwise clean + * capture; every other unreadable candidate stays fail-closed. + */ +const environmentWithheldFromInspection = ( + entry: ProcessTableEntry, + diagnostic: string, +): boolean => { + const uid = currentProcessUid(); + if (uid !== undefined && entry.uid !== undefined && entry.uid !== uid) + return true; + return diagnostic === PLATFORM_BINARY_ENVIRONMENT_WITHHELD; +}; + const scanTokenOwnedProcesses = async ( processes: readonly ProcessTableEntry[], runId: string, host: ProcessOwnershipHost, captureBaseline?: ProcessOwnershipBaseline, + relation?: OwnershipSweepRelation, ): Promise => { let candidates = processes; let candidateIdentities: ReadonlyMap = @@ -678,61 +779,98 @@ const scanTokenOwnedProcesses = async ( } candidates = stableCandidates; } + const owned: ProcessTableEntry[] = []; + const ownedIdentities = new Map(); + const recordOwned = (process: ProcessTableEntry): void => { + owned.push(process); + const identity = candidateIdentities.get(process.pid); + ownedIdentities.set( + process.pid, + identity?.state === "readable" ? identity.identity : null, + ); + }; let bulkTokens: ReadonlyMap | undefined; if (host.runTokens !== undefined) bulkTokens = await host.runTokens(candidates); - const owned: ProcessTableEntry[] = []; - const ownedIdentities = new Map(); + const unreadable: UnreadableTokenCandidate[] = []; for (const process of candidates) { const bulkToken = bulkTokens?.get(process.pid); if (bulkToken !== undefined) { if (bulkToken.state === "readable") { - if (bulkToken.runId === runId) { - owned.push(process); - const identity = candidateIdentities.get(process.pid); - ownedIdentities.set( - process.pid, - identity?.state === "readable" ? identity.identity : null, - ); - } + if (bulkToken.runId === runId) recordOwned(process); continue; } - if (!(await processIsGone(host, process.pid))) - failures.push({ - pid: process.pid, - reason: "environment-unreadable", - diagnostic: bulkToken.reason, - }); + unreadable.push({ process, diagnostic: bulkToken.reason }); continue; } try { - if ((await host.environment(process.pid)).REA_PROCESS_RUN_ID === runId) { - owned.push(process); - const identity = candidateIdentities.get(process.pid); - ownedIdentities.set( - process.pid, - identity?.state === "readable" ? identity.identity : null, - ); - } + if ((await host.environment(process.pid)).REA_PROCESS_RUN_ID === runId) + recordOwned(process); } catch (cause: unknown) { - try { - const live = liveProcesses(await host.listProcesses()); - if (!live.some(({ pid }) => pid === process.pid)) continue; - } catch (recheckCause: unknown) { - failures.push({ - pid: process.pid, - reason: "environment-unreadable", - diagnostic: `${errorMessage(cause)}; process liveness recheck failed: ${errorMessage(recheckCause)}`, - }); - continue; - } - failures.push({ - pid: process.pid, - reason: "environment-unreadable", - diagnostic: errorMessage(cause), - }); + unreadable.push({ process, diagnostic: errorMessage(cause) }); } } + const remaining = await settleUnreadableTokenCandidates( + unreadable, + runId, + host, + recordOwned, + ); + const relatedPids = new Set(); + const relatedGroupIds = new Set(); + if (relation !== undefined) { + relatedPids.add(relation.leaderPid); + for (const descendant of descendantsOf(relation.leaderPid, processes)) + relatedPids.add(descendant.pid); + relatedGroupIds.add(relation.processGroupId); + for (const processGroupId of relation.sampledProcessGroupIds ?? []) + if (Number.isSafeInteger(processGroupId) && processGroupId > 0) + relatedGroupIds.add(processGroupId); + for (const process of owned) relatedGroupIds.add(process.processGroupId); + } + const unverified: OwnershipSweepUnverifiedProcess[] = []; + const strict: UnreadableTokenCandidate[] = []; + const withheld: UnreadableTokenCandidate[] = []; + for (const candidate of remaining) { + const isRelated = + relation === undefined || + relatedPids.has(candidate.process.pid) || + relatedGroupIds.has(candidate.process.processGroupId); + if ( + !isRelated && + environmentWithheldFromInspection(candidate.process, candidate.diagnostic) + ) { + withheld.push(candidate); + continue; + } + strict.push(candidate); + } + let finalLive: ReadonlySet | undefined; + try { + finalLive = new Set( + liveProcesses(await host.listProcesses()).map(({ pid }) => pid), + ); + } catch (cause: unknown) { + void cause; + finalLive = undefined; + } + for (const candidate of strict) { + if (finalLive !== undefined && !finalLive.has(candidate.process.pid)) + continue; + failures.push({ + pid: candidate.process.pid, + reason: "environment-unreadable", + diagnostic: candidate.diagnostic, + }); + } + for (const candidate of withheld) { + if (finalLive !== undefined && !finalLive.has(candidate.process.pid)) + continue; + unverified.push({ + pid: candidate.process.pid, + diagnostic: candidate.diagnostic, + }); + } if (candidates.length > 0 && host.processIdentities !== undefined) { const afterRead = await host.processIdentities(candidates); const stillOwned: ProcessTableEntry[] = []; @@ -756,9 +894,91 @@ const scanTokenOwnedProcesses = async ( "process identity changed or became unavailable during token validation", }); } - return { owned: stillOwned, failures, identities: ownedIdentities }; + return { + owned: stillOwned, + failures, + unverified, + identities: ownedIdentities, + }; + } + return { owned, failures, unverified, identities: ownedIdentities }; +}; + +/** + * Re-read run tokens for unresolved sweep candidates so transient exec and + * exit states settle before classification. Each attempt revalidates + * liveness, refreshes the diagnostic, and is bounded by a fixed attempt + * budget; a still-unreadable candidate keeps its latest diagnostic. + */ +const settleUnreadableTokenCandidates = async ( + pending: readonly UnreadableTokenCandidate[], + runId: string, + host: ProcessOwnershipHost, + recordOwned: (process: ProcessTableEntry) => void, +): Promise => { + let remaining = pending; + for ( + let attempt = 0; + attempt < TOKEN_READ_RETRY_ATTEMPTS && remaining.length > 0; + attempt += 1 + ) { + await new Promise((resolve) => + setTimeout(resolve, TOKEN_READ_RETRY_DELAY_MS), + ); + let live: ReadonlySet | undefined; + try { + live = new Set( + liveProcesses(await host.listProcesses()).map(({ pid }) => pid), + ); + } catch (cause: unknown) { + void cause; + live = undefined; + } + let observations: + | ReadonlyMap + | undefined; + if (host.runTokens !== undefined) { + try { + observations = await host.runTokens( + remaining.map(({ process }) => process), + ); + } catch (cause: unknown) { + void cause; + observations = undefined; + } + } + const stillUnreadable: UnreadableTokenCandidate[] = []; + for (const candidate of remaining) { + const pid = candidate.process.pid; + if (live !== undefined && !live.has(pid)) continue; + const observation = + observations?.get(pid) ?? (await readEnvironmentRunToken(host, pid)); + if (observation.state === "readable") { + if (observation.runId === runId) recordOwned(candidate.process); + continue; + } + stillUnreadable.push({ + process: candidate.process, + diagnostic: observation.reason, + }); + } + remaining = stillUnreadable; + } + return remaining; +}; + +const readEnvironmentRunToken = async ( + host: ProcessOwnershipHost, + pid: number, +): Promise => { + try { + return { + state: "readable", + runId: (await host.environment(pid)).REA_PROCESS_RUN_ID, + }; + } catch (cause: unknown) { + return { state: "unavailable", reason: errorMessage(cause) }; } - return { owned, failures, identities: ownedIdentities }; }; const processIsGone = async ( diff --git a/src/process/ProcessOwnershipObservation.ts b/src/process/ProcessOwnershipObservation.ts index c33663f022..d47af781c1 100644 --- a/src/process/ProcessOwnershipObservation.ts +++ b/src/process/ProcessOwnershipObservation.ts @@ -192,19 +192,22 @@ export const createSystemProcessOwnershipHost = ( if (platform === "win32") return []; const { stdout } = await execFileOutput( "ps", - ["-axo", "pid=,ppid=,pgid=,stat=,command="], + ["-axo", "pid=,ppid=,pgid=,uid=,stat=,command="], { env: hostEnvironment, ...(signal === undefined ? {} : { signal }) }, ); return stdout .split("\n") - .map((line) => /\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.*)/u.exec(line)) + .map((line) => + /\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.*)/u.exec(line), + ) .filter((match): match is RegExpExecArray => match !== null) .map((match) => ({ pid: Number(match[1]), parentPid: Number(match[2]), processGroupId: Number(match[3]), - state: match[4] ?? "", - command: match[5] ?? "", + uid: Number(match[4]), + state: match[5] ?? "", + command: match[6] ?? "", })); }; const processIdentities: NonNullable< diff --git a/src/process/capture/ProcessCaptureLifecycle.ts b/src/process/capture/ProcessCaptureLifecycle.ts index 5cd75bf1a2..50f9d8e1d5 100644 --- a/src/process/capture/ProcessCaptureLifecycle.ts +++ b/src/process/capture/ProcessCaptureLifecycle.ts @@ -704,10 +704,18 @@ export const releaseProcessResources = async (options: { "owned process cleanup is unverifiable on Windows without process-job authority", }; } else { + const terminalPid = options.terminal.pid; + const relation = { + leaderPid: terminalPid, + processGroupId: terminalPid, + ...(options.sampledProcessGroupIds === undefined + ? {} + : { sampledProcessGroupIds: options.sampledProcessGroupIds }), + }; const cleaned = await host.cleanupProcessGroup({ runId: options.runId, - leaderPid: options.terminal.pid, - processGroupId: options.terminal.pid, + leaderPid: terminalPid, + processGroupId: terminalPid, sweepTokenOwnedProcesses: true, ...(options.sampledProcessGroupIds === undefined ? {} @@ -716,6 +724,7 @@ export const releaseProcessResources = async (options: { ? {} : { captureBaseline: options.captureBaseline }), }); + const unverified = [...(cleaned.unverified ?? [])]; if (!cleaned.cleaned) { ownedProcessGroup = { state: "unverified", reason: cleaned.reason }; } else { @@ -724,11 +733,25 @@ export const releaseProcessResources = async (options: { options.runId, undefined, options.captureBaseline, + relation, ), ); + unverified.push(...(verified.unverified ?? [])); if (!verified.cleaned) ownedProcessGroup = { state: "unverified", reason: verified.reason }; } + if (unverified.length > 0) + ownedProcessGroup = { + ...ownedProcessGroup, + unverified_processes: [ + ...new Map( + unverified.map(({ pid, diagnostic }) => [ + `${String(pid)}:${diagnostic}`, + { pid, reason: diagnostic }, + ]), + ).values(), + ], + }; } } let temporaryRoot: ProcessCaptureCleanupReport["temporary_root"] = { @@ -892,7 +915,8 @@ export const resolveProcessResult = ( } if (capture === undefined) throw new ProcessCaptureError("process capture produced no result"); - if ("cleanup" in capture) return capture; + const unverifiedProcesses = cleanup.owned_process_group.unverified_processes; + if ("cleanup" in capture && unverifiedProcesses === undefined) return capture; const settlement: UnverifiedProcessCapture["settlement"] = capture.settlement.state === "quiesced" @@ -902,9 +926,20 @@ export const resolveProcessResult = ( return parseProcessCapture({ ...capture, settlement, + residual_unknowns: [ + ...capture.residual_unknowns, + ...(unverifiedProcesses ?? []).map(({ pid, reason }) => ({ + scope: "process" as const, + reason: `Ownership of unrelated process ${String(pid)} could not be verified; it was left untouched: ${reason}`, + })), + ], cleanup: { + ...("cleanup" in capture ? capture.cleanup : {}), owned_process_group: "verified", temporary_root: "removed", + ...(unverifiedProcesses === undefined + ? {} + : { unverified_processes: unverifiedProcesses }), }, }); } catch (cause: unknown) { diff --git a/src/process/capture/ProcessHarness.test.ts b/src/process/capture/ProcessHarness.test.ts index 4108f61624..961276809a 100644 --- a/src/process/capture/ProcessHarness.test.ts +++ b/src/process/capture/ProcessHarness.test.ts @@ -142,6 +142,64 @@ it("waits for token-owned processes to exit after one cleanup signal", async () } }); +it("retains unrelated withheld processes through cleanup, capture, and JSON", async () => { + const host: ProcessCaptureCleanupHost = { + platform: "darwin", + cleanupProcessGroup: async () => ({ + cleaned: true, + signaled: true, + unverified: [ + { pid: 900, diagnostic: "platform_binary_environment_withheld" }, + ], + }), + verifyTokenOwnedProcesses: async () => ({ + cleaned: true, + signaled: false, + unverified: [{ pid: 901, diagnostic: "foreign uid: EINVAL" }], + }), + removeTemporaryRoot: async () => undefined, + }; + const report = await releaseProcessResources({ + timers: new Set(), + terminal: { pid: 321 }, + renderer: undefined, + runId: "fixture-run", + temporaryRoot: "/fixture/root", + host, + }); + const unverified = [ + { pid: 900, reason: "platform_binary_environment_withheld" }, + { pid: 901, reason: "foreign uid: EINVAL" }, + ]; + expect(report.owned_process_group).toEqual({ + state: "cleaned", + reason: null, + unverified_processes: unverified, + }); + const original = emptyProcessCapture(); + const { cleanup: previousCleanup, ...pending } = original; + for (const input of [pending, original]) { + const resolved = resolveProcessResult(input, undefined, report); + const serialized: unknown = JSON.parse(JSON.stringify(resolved)); + const capture = parseProcessCapture(serialized); + expect(capture.cleanup).toEqual({ + ...previousCleanup, + unverified_processes: unverified, + }); + expect(capture.residual_unknowns).toEqual([ + ...original.residual_unknowns, + { + scope: "process", + reason: expect.stringContaining("900 could not be verified"), + }, + { + scope: "process", + reason: expect.stringContaining("901 could not be verified"), + }, + ]); + } +}); + it("retains the last ownership failure when the verification grace expires", async () => { vi.useFakeTimers(); try { @@ -246,6 +304,9 @@ it("retains observations and both causes when process cleanup is unverifiable", state: "unverified" as const, reason: "process ownership token could not be read for 1 live process(es): environment_unavailable=1", + unverified_processes: [ + { pid: 900, reason: "platform_binary_environment_withheld" }, + ], }, terminal_renderer: { state: "cleaned" as const, reason: null }, temporary_root: { state: "cleaned" as const, reason: null }, diff --git a/tests/acceptance/webModuleCli.test.ts b/tests/acceptance/webModuleCli.test.ts index fc4d7bd88f..5bab64449f 100644 --- a/tests/acceptance/webModuleCli.test.ts +++ b/tests/acceptance/webModuleCli.test.ts @@ -24,7 +24,7 @@ cliTest( join(root, "files", "modules", "main.js"), artifacts.source, ); - const manifestPath = join(root, "manifest.json"); + const manifestPath = join(root, "manifest.json").replaceAll("\\", "/"); await writeFile(manifestPath, JSON.stringify(manifest)); const environment = { REA_LOG_LEVEL: "silent", diff --git a/tests/acceptance/webSourceLocationCli.test.ts b/tests/acceptance/webSourceLocationCli.test.ts index c7ca688e2d..2e7b3a2fe3 100644 --- a/tests/acceptance/webSourceLocationCli.test.ts +++ b/tests/acceptance/webSourceLocationCli.test.ts @@ -1,4 +1,10 @@ -import { expect } from "vitest"; +import { expect, onTestFinished } from "vitest"; +import { Client } from "@modelcontextprotocol/client"; +import { + StdioClientTransport, + getDefaultEnvironment, +} from "@modelcontextprotocol/client/stdio"; +import { fileURLToPath } from "node:url"; import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { webSourceLocationFixture } from "../fixtures/webSourceLocation.js"; @@ -12,7 +18,7 @@ cliTest( const fixture = webSourceLocationFixture(); await mkdir(join(root, "files", "modules"), { recursive: true }); await writeFile(join(root, "files", "modules", "main.js"), fixture.source); - const manifest = join(root, "manifest.json"); + const manifest = join(root, "manifest.json").replaceAll("\\", "/"); const map = join(root, "app.js.map"); await writeFile(manifest, JSON.stringify(fixture.manifest)); await writeFile(map, fixture.sourceMap.text); @@ -44,6 +50,40 @@ cliTest( runtime: { id: "node-v8" }, }, }); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [ + fileURLToPath(new URL("../../scripts/rea.mjs", import.meta.url)), + "mcp", + ], + env: { ...getDefaultEnvironment(), REA_LOG_LEVEL: "silent" }, + stderr: "pipe", + }); + const client = new Client({ + name: "source-location-cli-parity", + version: "1", + }); + onTestFinished(async () => { + try { + await client.close(); + } finally { + await transport.close(); + } + }); + await client.connect(transport); + const mcpResponse = await client.callTool({ + name: "trace_web_source_location", + arguments: { + manifest_path: manifest, + script_index: 0, + source_map: { path: map, url: fixture.sourceMap.url }, + generated_position: { line: 1, column: 0 }, + }, + }); + expect(mcpResponse.isError, JSON.stringify(mcpResponse)).not.toBe(true); + expect(mcpResponse.structuredContent).toMatchObject({ + evidence: response.json, + }); const invalid = await cli.run({ arguments: [ "trace-web-source-location", diff --git a/tests/boundary/cli/productCatalog.test.ts b/tests/boundary/cli/productCatalog.test.ts index 3eb9f6528e..6dcc93b66d 100644 --- a/tests/boundary/cli/productCatalog.test.ts +++ b/tests/boundary/cli/productCatalog.test.ts @@ -95,7 +95,7 @@ describe("canonical product catalog", () => { ); }); - it("matches every source-derived checked-in product fact", async () => { + it("matches every source-derived build-generated product fact", async () => { const catalog = await createProductCatalog(root); expect(catalog.tools.total).toBe(TOOL_CONTRACTS.length); expect( @@ -195,14 +195,19 @@ describe("canonical product catalog", () => { "project_managed_application_graph", "verify_managed_native_boundaries", ]); + // Runtime schema commitments must not fan out into checked-in documentation. + // Provider commitments cover the facts actually present in this projection. + expect(Object.keys(catalog.runtime_catalog.digests)).toEqual([ + "providers_sha256", + ]); expect(catalog.runtime_catalog.digests.providers_sha256).toBe( providerCatalogDigest(catalog.providers), ); expect( - JSON.parse(await readFile("docs/product-catalog.json", "utf8")), + JSON.parse(await readFile("docs/public/product-catalog.json", "utf8")), ).toEqual(catalog); expect(await serializeProductCatalog(catalog)).toBe( - await readFile("docs/product-catalog.json", "utf8"), + await readFile("docs/public/product-catalog.json", "utf8"), ); await expect( assertDocumentationFacts(root, catalog), diff --git a/tests/boundary/filesystem/dependencyInstall.test.ts b/tests/boundary/filesystem/dependencyInstall.test.ts index fac9035c99..39c771791d 100644 --- a/tests/boundary/filesystem/dependencyInstall.test.ts +++ b/tests/boundary/filesystem/dependencyInstall.test.ts @@ -164,7 +164,7 @@ const createBuildCacheFixture = async (): Promise => { "scripts/clean-build-output.mjs", "scripts/generate-package-metadata.mjs", "scripts/lib/generated-file.mjs", - "skills/reverse-engineer-anything/SKILL.md", + "skill-src/reverse-engineer-anything/SKILL.md", "src/generatedPackageMetadata.ts", ]; await Promise.all( diff --git a/tests/boundary/filesystem/evidenceBundleFiles.test.ts b/tests/boundary/filesystem/evidenceBundleFiles.test.ts index e19ceee379..efc1cd08f4 100644 --- a/tests/boundary/filesystem/evidenceBundleFiles.test.ts +++ b/tests/boundary/filesystem/evidenceBundleFiles.test.ts @@ -10,6 +10,7 @@ import { writeEvidenceBundle, } from "../../../src/application/EvidenceBundleFiles.js"; import { compareEvidenceBundlesCommand } from "../../../src/application/EvidenceBundleCommands.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { createEvidence } from "../../../src/domain/evidence.js"; import { createEvidenceBundle, @@ -116,6 +117,32 @@ describe("evidence bundle filesystem adapter", () => { }); }); + it("reports a missing file or output directory with the selected path", async () => { + const directory = await createTestTempDirectory("rea-evidence-"); + const absent = join(directory, "absent.json"); + const read = await readEvidenceBundle(absent); + expect(read).toMatchObject({ + ok: false, + error: { _tag: "EvidenceFileError", reason: "missing", path: absent }, + }); + if (read.ok) throw new Error("Expected a missing-file failure"); + expect(projectAnalysisError(read.error)).toMatchObject({ + message: expect.stringContaining("does not exist at the selected path"), + details: { operation: "read", reason: "missing", path: absent }, + }); + + const orphan = join(directory, "absent", "bundle.json"); + const written = await writeEvidenceBundle(bundle(), orphan, false); + expect(written).toMatchObject({ + ok: false, + error: { _tag: "EvidenceFileError", reason: "missing", path: orphan }, + }); + if (written.ok) throw new Error("Expected a missing-directory failure"); + expect(projectAnalysisError(written.error).message).toContain( + "output directory does not exist", + ); + }); + it("rejects malformed and tampered input", async () => { const directory = await createTestTempDirectory("rea-evidence-"); const malformed = join(directory, "malformed.json"); @@ -136,7 +163,55 @@ describe("evidence bundle filesystem adapter", () => { ); expect(await readEvidenceBundle(tamperedPath)).toMatchObject({ ok: false, - error: { _tag: "EvidenceIntegrityError" }, + error: { + _tag: "EvidenceIntegrityError", + userMessage: expect.stringContaining( + "Evidence semantic identifier does not match its record", + ), + }, + }); + }); + + it("names the failed bundle constraint for a single record or schema mismatch", async () => { + const directory = await createTestTempDirectory("rea-evidence-"); + const record = bundle().records[0]; + const single = join(directory, "record.json"); + await writeFile(single, JSON.stringify(record)); + expect(await readEvidenceBundle(single)).toMatchObject({ + ok: false, + error: { + _tag: "EvidenceIntegrityError", + userMessage: expect.stringContaining( + `this JSON is one Evidence record (${String(record?.evidence_id)})`, + ), + }, + }); + + const partial = join(directory, "partial.json"); + await writeFile(partial, JSON.stringify({ evidence_id: "typo" })); + const rejected = await readEvidenceBundle(partial); + expect(rejected).toMatchObject({ + ok: false, + error: { + userMessage: expect.stringContaining( + "does not match the bundle schema", + ), + }, + }); + expect(rejected).not.toMatchObject({ + error: { userMessage: expect.stringContaining("one Evidence record") }, + }); + + const mismatched = join(directory, "mismatched.json"); + await writeFile(mismatched, JSON.stringify({ ...bundle(), records: {} })); + expect(await readEvidenceBundle(mismatched)).toMatchObject({ + ok: false, + error: { + _tag: "EvidenceIntegrityError", + userMessage: expect.stringContaining( + "does not match the bundle schema at records:", + ), + }, }); }); }); diff --git a/tests/boundary/filesystem/generatedSkill.test.ts b/tests/boundary/filesystem/generatedSkill.test.ts new file mode 100644 index 0000000000..c5db95c1a0 --- /dev/null +++ b/tests/boundary/filesystem/generatedSkill.test.ts @@ -0,0 +1,125 @@ +import { execFile } from "node:child_process"; +import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const exec = promisify(execFile); +const repositoryRoot = fileURLToPath(new URL("../../..", import.meta.url)); +const authored = + '---\nname: reverse-engineer-anything\nmetadata:\n version: "33"\n---\n\n# Instructions\n\nInspect the caller-selected artifact.\n'; + +async function fixture(eol = "\n") { + const root = await createTestTempDirectory("rea-generated-skill-"); + for (const path of [ + "scripts/generate-skill-metadata.mjs", + "scripts/lib/generated-file.mjs", + ]) { + await mkdir(dirname(join(root, path)), { recursive: true }); + await copyFile(join(repositoryRoot, path), join(root, path)); + } + const sourceRoot = join(root, "skill-src/reverse-engineer-anything"); + const outputRoot = join(root, "skills/reverse-engineer-anything"); + await mkdir(join(sourceRoot, "references"), { recursive: true }); + await mkdir(join(root, "dist")); + await writeFile(join(root, "package.json"), '{"type":"module"}\n'); + await writeFile(join(sourceRoot, "SKILL.md"), authored.replaceAll("\n", eol)); + await writeFile( + join(sourceRoot, "references/guide.md"), + `# Guide${eol}Preserve evidence.${eol}`, + ); + const identity = async (count: number, digest: string) => + writeFile( + join(root, "dist/catalogIdentity.js"), + `export const CATALOG_IDENTITY = ${JSON.stringify({ counts: { mcp_tools: count }, digests: { combined_sha256: digest } })};\n`, + ); + await identity(139, "a".repeat(64)); + const generate = (...args: string[]) => + exec( + process.execPath, + [join(root, "scripts/generate-skill-metadata.mjs"), ...args], + { cwd: root }, + ); + return { sourceRoot, outputRoot, identity, generate }; +} + +it.each(["\n", "\r\n"])( + "generates revision-specific metadata without rewriting authored instructions (%j)", + async (eol) => { + const f = await fixture(eol); + await f.generate(); + const first = await readFile(join(f.outputRoot, "SKILL.md"), "utf8"); + expect(first).not.toContain("catalog_digest:"); + expect(first).toContain("tool_count: 139"); + expect( + first.replace(/^ {2}(?:tool_count|catalog_digest):[^\n]*\n/gmu, ""), + ).toBe(authored); + expect( + await readFile(join(f.outputRoot, "references/guide.md"), "utf8"), + ).toBe("# Guide\nPreserve evidence.\n"); + await f.identity(139, "b".repeat(64)); + await f.generate(); + expect(await readFile(join(f.outputRoot, "SKILL.md"), "utf8")).toBe(first); + await f.identity(140, "b".repeat(64)); + await expect(f.generate("--check")).rejects.toMatchObject({ + stderr: expect.stringContaining("missing or stale"), + }); + await f.generate(); + expect(await readFile(join(f.outputRoot, "SKILL.md"), "utf8")).toContain( + "tool_count: 140", + ); + expect(await readFile(join(f.sourceRoot, "SKILL.md"), "utf8")).toBe( + authored.replaceAll("\n", eol), + ); + expect( + await readFile(join(f.sourceRoot, "references/guide.md"), "utf8"), + ).toBe(`# Guide${eol}Preserve evidence.${eol}`); + await expect(f.generate("--check")).resolves.toMatchObject({ stderr: "" }); + }, +); + +it("rejects missing, stale, and extra packaged references and removes deleted source files on rebuild", async () => { + const f = await fixture(); + await f.generate(); + const guide = join(f.outputRoot, "references/guide.md"); + await rm(guide); + await expect(f.generate("--check")).rejects.toMatchObject({ + stderr: expect.stringContaining("guide.md is missing or stale"), + }); + await f.generate(); + await writeFile(guide, "modified reference\n"); + await expect(f.generate("--check")).rejects.toMatchObject({ + stderr: expect.stringContaining("guide.md is missing or stale"), + }); + await f.generate(); + await writeFile( + join(f.outputRoot, "references/removed.md"), + "obsolete instructions\n", + ); + await expect(f.generate("--check")).rejects.toMatchObject({ + stderr: expect.stringContaining("file inventory drifted"), + }); + await rm(join(f.sourceRoot, "references/guide.md")); + await f.generate(); + await expect(readFile(guide)).rejects.toMatchObject({ code: "ENOENT" }); + await expect( + readFile(join(f.outputRoot, "references/removed.md")), + ).rejects.toMatchObject({ code: "ENOENT" }); + await f.generate("--check"); +}); + +it("rejects catalog-dependent metadata in the authored source", async () => { + const f = await fixture(); + await writeFile( + join(f.sourceRoot, "SKILL.md"), + authored.replace(' version: "33"', ' version: "33"\n tool_count: 139'), + ); + await expect(f.generate()).rejects.toMatchObject({ + stderr: expect.stringContaining( + "Catalog metadata belongs in the generated skill", + ), + }); +}); diff --git a/tests/boundary/filesystem/keyedArchiveSelection.test.ts b/tests/boundary/filesystem/keyedArchiveSelection.test.ts new file mode 100644 index 0000000000..28ebed8476 --- /dev/null +++ b/tests/boundary/filesystem/keyedArchiveSelection.test.ts @@ -0,0 +1,99 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { build } from "plist"; +import { expect, it } from "vitest"; + +import { ArtifactReaderFailure } from "../../../src/artifacts/ArtifactReader.js"; +import { inspectBundleKeyedArchive } from "../../../src/artifacts/apple/KeyedArchiveReader.js"; +import { AnalysisInputError } from "../../../src/domain/analysisErrorCore.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const archive = build({ + $archiver: "NSKeyedArchiver", + $version: 100000, + $objects: ["$null", "value"], + $top: { root: { CF$UID: 1 }, other: { CF$UID: 1 } }, +}); + +const bundle = async () => { + const root = await createTestTempDirectory("rea-keyed-selection-"); + await mkdir(join(root, "Contents", "Resources"), { recursive: true }); + await writeFile( + join(root, "Contents", "Resources", "Model.plist"), + Buffer.from(archive), + ); + return root; +}; + +const inspect = (bundlePath: string, parameters: Record) => + inspectBundleKeyedArchive({ + bundlePath, + targetSha256: "a".repeat(64), + parameters, + }); + +it.each([ + [".", "invalid_format", "relative to the active app bundle"], + ["Contents/../Model.plist", "invalid_format", "canonical relative"], + ["Contents//Model.plist", "invalid_format", "canonical relative"], + ["Contents/Resources", "invalid_value", "selects a directory"], + ["Contents/Resources/Missing.plist", "invalid_value", "No regular file"], +] as const)( + "reports caller-selected archive path %j as invalid input", + async (path, reason, message) => { + const root = await bundle(); + const rejected = await inspect(root, { path }).catch( + (cause: unknown) => cause, + ); + expect(rejected).toBeInstanceOf(AnalysisInputError); + expect(rejected).toMatchObject({ + issues: [ + { + path: ["path"], + reason, + message: expect.stringContaining(message), + }, + ], + }); + }, +); + +it("reports an absent named root with the archive's available roots", async () => { + const root = await bundle(); + await expect( + inspect(root, { path: "Contents/Resources/Model.plist", root: "missing" }), + ).rejects.toMatchObject({ + issues: [ + { + path: ["root"], + reason: "invalid_value", + message: expect.stringContaining("missing"), + expected: ["root", "other"], + }, + ], + }); +}); + +it.each([ + ["plain plist", Buffer.from(build({ plain: true })), "NSKeyedArchiver"], + [ + "compiled NIBArchive", + Buffer.concat([Buffer.from("NIBArchive"), Buffer.alloc(40)]), + "decode_interface_builder", + ], +])( + "keeps the decoder reason for a selected %s", + async (_label, bytes, detail) => { + const root = await bundle(); + await writeFile(join(root, "Contents", "Resources", "Other.nib"), bytes); + const rejected = await inspect(root, { + path: "Contents/Resources/Other.nib", + }).catch((cause: unknown) => cause); + expect(rejected).toBeInstanceOf(ArtifactReaderFailure); + expect(rejected).toMatchObject({ + reason: "format", + message: expect.stringContaining(detail), + }); + }, +); diff --git a/tests/boundary/filesystem/releaseCheckpoint.test.ts b/tests/boundary/filesystem/releaseCheckpoint.test.ts index 6ab3e24966..00fff50879 100644 --- a/tests/boundary/filesystem/releaseCheckpoint.test.ts +++ b/tests/boundary/filesystem/releaseCheckpoint.test.ts @@ -50,7 +50,6 @@ async function metadata(directory: string, version: string, notes: string) { version, packages: [{ identifier: "rea-agents", version }], }, - "docs/product-catalog.json": { package: { version } }, "release-please-config.json": { "changelog-sections": [ { type: "feat", hidden: false }, @@ -371,7 +370,6 @@ it("validates candidate options before inspecting the working directory", async it.each([ "package-lock.json", "server.json", - "docs/product-catalog.json", "src/generatedPackageMetadata.ts", ])("rejects inconsistent version metadata in %s", async (path) => { const f = await fixture(); diff --git a/tests/boundary/filesystem/releaseWorkflow.test.ts b/tests/boundary/filesystem/releaseWorkflow.test.ts index 7385c3e4f7..c8d404ef18 100644 --- a/tests/boundary/filesystem/releaseWorkflow.test.ts +++ b/tests/boundary/filesystem/releaseWorkflow.test.ts @@ -103,11 +103,11 @@ it("requires explicit release preparation or publication instead of main pushes" "skip-github-release": "${{ inputs.phase == 'prepare' }}", "skip-github-pull-request": "${{ inputs.phase == 'publish' }}", }); - const catalogCommit = workflow.jobs["release-please"].steps.find( - (step) => step.name === "Commit canonical release catalog", + const catalogValidation = workflow.jobs["release-please"].steps.find( + (step) => step.name === "Validate generated release documentation", ); - expect(catalogCommit?.env?.GH_TOKEN).toBe( - "${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}", + expect(catalogValidation?.run).toBe( + "npm run docs:check && git diff --exit-code", ); }); @@ -203,7 +203,7 @@ it("binds npm and MCP publication to the same immutable release SHA", async () = "Set up Node.js for generated documentation", "Install dependencies", "Regenerate release documentation", - "Commit canonical release catalog", + "Validate generated release documentation", ]; for (const name of preparation) { expect( diff --git a/tests/boundary/filesystem/setupSkill.test.ts b/tests/boundary/filesystem/setupSkill.test.ts index a0f0b47383..e90adeb1ba 100644 --- a/tests/boundary/filesystem/setupSkill.test.ts +++ b/tests/boundary/filesystem/setupSkill.test.ts @@ -1,4 +1,5 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { describe, expect, it } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -7,8 +8,14 @@ import { canonicalSkillNeedsInstall, installCanonicalSkill, } from "../../../src/application/SetupSkill.js"; +import { + runDoctor, + systemDoctorHost, +} from "../../../src/application/Doctor.js"; +import { createDoctorHostFixture } from "../../../src/application/Doctor.fixture.js"; import { TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.js"; import { PRODUCT_IDENTITY } from "../../../src/identity.js"; +import { z } from "zod"; import { skillReferenceIssues } from "../../../scripts/lib/docs-facts.mjs"; describe("canonical skill transaction", () => { @@ -83,6 +90,7 @@ describe("canonical skill transaction", () => { expect(installedSkill).toContain( `tool_count: ${String(TOOL_CONTRACTS.length)}`, ); + expect(installedSkill).not.toContain("catalog_digest:"); expect(await readFile(sibling, "utf8")).toBe("unrelated skill\n"); expect( await readFile( @@ -97,4 +105,102 @@ describe("canonical skill transaction", () => { expect(await installCanonicalSkill(home)).toBe("unchanged"); expect(await skillReferenceIssues(dirname(destination))).toEqual([]); }); + + it("binds portable conformance to the generated skill bytes that setup installs", async () => { + const home = await createTestTempDirectory("rea-skill-commitment-"); + expect(await installCanonicalSkill(home)).toBe("installed"); + const paths = [ + "SKILL.md", + "references/android-applications.md", + "references/evidence-workflows.md", + "references/javascript-applications.md", + "references/native-and-artifacts.md", + "references/runtime-observation.md", + ]; + const records = await Promise.all( + paths.map(async (path) => { + const bytes = await readFile( + join(home, ".agents/skills/reverse-engineer-anything", path), + ); + return `${path}\0${createHash("sha256").update(bytes).digest("hex")}\n`; + }), + ); + const manifest = z + .object({ + skill_digests: z.array( + z.object({ skill_id: z.string(), sha256: z.string() }), + ), + }) + .parse( + JSON.parse( + await readFile( + "docs/verification/managed-conformance-manifest.json", + "utf8", + ), + ), + ); + expect(manifest.skill_digests).toContainEqual({ + skill_id: "reverse-engineer-anything", + sha256: createHash("sha256").update(records.join("")).digest("hex"), + }); + }); +}); + +it("doctor verifies installed instructions and references even when metadata is current", async () => { + const home = await createTestTempDirectory("rea-skill-doctor-"); + const destination = join( + home, + ".agents/skills/reverse-engineer-anything/SKILL.md", + ); + const reference = join( + dirname(destination), + "references/evidence-workflows.md", + ); + const system = systemDoctorHost({ + environment: { HOME: home, USERPROFILE: home }, + }); + const host = createDoctorHostFixture({ + installedSkillIdentity: () => + system.installedSkillIdentity?.() ?? Promise.resolve(undefined), + }); + expect((await runDoctor(undefined, host)).identity?.skill.state).toBe( + "missing", + ); + expect(await installCanonicalSkill(home)).toBe("installed"); + expect((await runDoctor(undefined, host)).identity?.skill).toMatchObject({ + state: "aligned", + installed_catalog_digest: null, + }); + const legacyDigest = "0".repeat(64); + await writeFile( + destination, + (await readFile(destination, "utf8")).replace( + ` tool_count: ${String(TOOL_CONTRACTS.length)}`, + ` tool_count: ${String(TOOL_CONTRACTS.length)}\n catalog_digest: "${legacyDigest}"`, + ), + ); + expect((await runDoctor(undefined, host)).identity?.skill).toMatchObject({ + state: "stale", + installed_catalog_digest: legacyDigest, + }); + expect(await installCanonicalSkill(home)).toBe("installed"); + for (const path of [destination, reference]) { + const canonical = await readFile(path, "utf8"); + await writeFile(path, `${canonical}\nLocally changed instructions.\n`); + expect((await runDoctor(undefined, host)).identity?.skill.state).toBe( + "stale", + ); + expect(await installCanonicalSkill(home)).toBe("installed"); + expect((await runDoctor(undefined, host)).identity?.skill.state).toBe( + "aligned", + ); + } + await rm(reference); + expect((await runDoctor(undefined, host)).identity?.skill.state).toBe( + "stale", + ); + expect(await installCanonicalSkill(home)).toBe("installed"); + expect((await runDoctor(undefined, host)).identity?.skill.state).toBe( + "aligned", + ); }); diff --git a/tests/boundary/filesystem/webScriptManifestPaths.test.ts b/tests/boundary/filesystem/webScriptManifestPaths.test.ts new file mode 100644 index 0000000000..9996fa8a99 --- /dev/null +++ b/tests/boundary/filesystem/webScriptManifestPaths.test.ts @@ -0,0 +1,77 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { isAbsolute, join, relative, sep } from "node:path"; +import { expect, it } from "vitest"; +import { LocalWebScriptArtifacts } from "../../../src/browser/assets/LocalWebScriptArtifacts.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { webModuleArtifactsFixture } from "../../fixtures/webModuleTrace.js"; + +const materialize = async () => { + const root = join( + await createTestTempDirectory("rea-script-manifest-path-"), + "export with spaces", + ); + const artifact = webModuleArtifactsFixture(); + await mkdir(join(root, "files", "modules"), { recursive: true }); + await mkdir(join(root, "nested")); + const sourcePath = join(root, "files", "modules", "main.js"); + const manifestPath = join(root, "manifest.json"); + await writeFile(sourcePath, artifact.source); + await writeFile(manifestPath, JSON.stringify(artifact.manifest)); + return { root, artifact, sourcePath, manifestPath }; +}; + +it("reads equivalent local manifest spellings without changing reported identity", async () => { + const fixture = await materialize(); + const paths = new Set([ + fixture.manifestPath, + fixture.manifestPath.replaceAll("\\", "/"), + `${fixture.root}${sep}nested${sep}..${sep}manifest.json`, + ]); + const fromWorkingDirectory = relative(process.cwd(), fixture.manifestPath); + // A target on another Windows drive has no relative path from this cwd. + if (!isAbsolute(fromWorkingDirectory)) + paths.add(`.${sep}${fromWorkingDirectory}`); + for (const operation of [ + "trace_web_module_imports", + "trace_web_source_location", + ] as const) { + for (const manifestPath of paths) { + const response = await new LocalWebScriptArtifacts(operation).load({ + manifest_path: manifestPath, + script_index: 0, + }); + if (!response.ok) throw response.error; + expect(response.value.source).toBe(fixture.artifact.source); + expect(response.value.sourceFile).toEqual({ + ...fixture.artifact.sourceFile, + path: fixture.sourcePath, + }); + expect(response.value.manifestFile.path).toBe(manifestPath); + expect(response.value.manifest.output_directory).toBe( + fixture.artifact.manifest.output_directory, + ); + expect(response.value.manifest.scripts[0]?.url).toBe( + fixture.artifact.manifest.scripts[0]?.url, + ); + } + } +}); + +it("still rejects changed source bytes after normalizing the selected manifest path", async () => { + const fixture = await materialize(); + await writeFile(fixture.sourcePath, "tampered source"); + const response = await new LocalWebScriptArtifacts( + "trace_web_module_imports", + ).load({ + manifest_path: `${fixture.root}/nested/../manifest.json`.replaceAll( + "\\", + "/", + ), + script_index: 0, + }); + if (response.ok) throw new Error("Expected source integrity mismatch"); + expect(projectAnalysisError(response.error).code).toBe( + "artifact_integrity_mismatch", + ); +}); diff --git a/tests/boundary/filesystem/windowsNpxRegistration.test.ts b/tests/boundary/filesystem/windowsNpxRegistration.test.ts new file mode 100644 index 0000000000..dc50f232ec --- /dev/null +++ b/tests/boundary/filesystem/windowsNpxRegistration.test.ts @@ -0,0 +1,168 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Client } from "@modelcontextprotocol/client"; +import { + StdioClientTransport, + getDefaultEnvironment, +} from "@modelcontextprotocol/client/stdio"; +import { expect, it } from "vitest"; +import { z } from "zod"; +import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js"; +import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; +import { setupRegistrationCommand } from "../../../src/application/SetupHost.js"; +import { systemUninstallHost } from "../../../src/application/Uninstall.js"; +import { PRODUCT_IDENTITY } from "../../../src/identity.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const configurationSchema = z.object({ + mcpServers: z.object({ + rea: z.object({ command: z.string(), args: z.array(z.string()) }), + }), +}); + +it("migrates bare Windows npx registrations idempotently and uninstalls only REA", async () => { + const home = await createTestTempDirectory("rea-windows-registration-"); + const configPath = join(home, ".claude.json"); + const original = JSON.stringify({ + theme: "dark", + mcpServers: { + other: { command: "another-server" }, + rea: { + command: "npx", + args: ["-y", PRODUCT_IDENTITY.registrationPackageSpecifier, "mcp"], + }, + }, + }); + await writeFile(configPath, original); + expect( + await readClientRegistrationStatuses(home, undefined, { + platform: "win32", + environment: {}, + }), + ).toContainEqual( + expect.objectContaining({ client: "claude_code", state: "stale" }), + ); + const client = { name: "claude_code", configPath, format: "json" } as const; + const command = setupRegistrationCommand("win32", true); + expect(await configureClientConfiguration(client, {}, command)).toMatchObject( + { status: "configured" }, + ); + expect( + await readClientRegistrationStatuses(home, undefined, { + platform: "win32", + environment: {}, + }), + ).toContainEqual( + expect.objectContaining({ client: "claude_code", state: "aligned" }), + ); + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "unchanged", + }); + expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(original); + expect(await systemUninstallHost(home).removeClient(client)).toMatchObject({ + status: "removed", + }); + const remaining: unknown = JSON.parse(await readFile(configPath, "utf8")); + expect(remaining).toEqual({ + theme: "dark", + mcpServers: { other: { command: "another-server" } }, + }); +}); + +it("keeps the Windows launcher aligned through Codex TOML setup and removal", async () => { + const home = await createTestTempDirectory("rea-windows-codex-"); + await mkdir(join(home, ".codex")); + const configPath = join(home, ".codex", "config.toml"); + const original = '[mcp_servers.other]\ncommand = "another-server"\n'; + await writeFile(configPath, original); + const client = { name: "codex", configPath, format: "toml" } as const; + const command = setupRegistrationCommand("win32", true); + expect(await configureClientConfiguration(client, {}, command)).toMatchObject( + { status: "configured" }, + ); + expect( + await readClientRegistrationStatuses(home, undefined, { + platform: "win32", + environment: {}, + }), + ).toContainEqual( + expect.objectContaining({ client: "codex", state: "aligned" }), + ); + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "unchanged", + }); + expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(original); + expect(await systemUninstallHost(home).removeClient(client)).toMatchObject({ + status: "removed", + }); + const remaining = await readFile(configPath, "utf8"); + expect(remaining).toContain("[mcp_servers.other]"); + expect(remaining).toContain('command = "another-server"'); + expect(remaining).not.toContain("mcp_servers.rea"); +}); + +it.skipIf(process.platform !== "win32")( + "starts the written Windows registration through a native npx.cmd shim", + async () => { + const root = await createTestTempDirectory("rea-windows-mcp-"); + const home = join(root, "home with spaces"); + await mkdir(home); + const configPath = join(home, ".claude.json"); + const setupClient = { + name: "claude_code", + configPath, + format: "json", + } as const; + // The default writer and npm-run setup must produce the same native launcher. + expect(await configureClientConfiguration(setupClient)).toMatchObject({ + status: "configured", + }); + const decoded: unknown = JSON.parse(await readFile(configPath, "utf8")); + const registration = configurationSchema.parse(decoded).mcpServers.rea; + expect([registration.command, ...registration.args]).toEqual( + setupRegistrationCommand("win32", true), + ); + const entry = fileURLToPath( + new URL("../../../scripts/rea.mjs", import.meta.url), + ); + // Exercise the actual batch-shim boundary without registry access or package installation. + await writeFile( + join(home, "npx.cmd"), + [ + "@echo off", + 'if not "%~1"=="-y" exit /b 11', + `if not "%~2"=="${PRODUCT_IDENTITY.registrationPackageSpecifier}" exit /b 12`, + 'if not "%~3"=="mcp" exit /b 13', + `"${process.execPath}" "${entry}" mcp`, + "", + ].join("\r\n"), + ); + const transport = new StdioClientTransport({ + command: registration.command, + args: registration.args, + cwd: home, + env: getDefaultEnvironment(), + stderr: "pipe", + }); + const client = new Client({ name: "windows-setup-launcher", version: "1" }); + try { + await client.connect(transport); + expect(client.getServerVersion()?.version).toBe( + PRODUCT_IDENTITY.packageVersion, + ); + const status = await client.callTool({ + name: "binary_session", + arguments: {}, + }); + expect(status.isError).not.toBe(true); + expect(status.structuredContent).toMatchObject({ + result: { open: false }, + }); + } finally { + await client.close(); + await transport.close(); + } + }, + 30_000, +); diff --git a/tests/boundary/mcp/artifactMcp.inlineInventory.test.ts b/tests/boundary/mcp/artifactMcp.inlineInventory.test.ts index 5afbb74ada..6d14f9df41 100644 --- a/tests/boundary/mcp/artifactMcp.inlineInventory.test.ts +++ b/tests/boundary/mcp/artifactMcp.inlineInventory.test.ts @@ -62,6 +62,40 @@ it("inspects a standalone keyed archive through MCP with original object identit arguments: { path: "other.plist" }, }); expect(rejected.isError).toBe(true); + expect(rejected.structuredContent).toMatchObject({ + error: { + code: "invalid_request", + details: { + issues: [ + { + path: ["path"], + reason: "invalid_value", + expected: "archive.plist", + }, + ], + }, + }, + }); + const missingRoot = await client.callTool({ + name: "inspect_keyed_archive", + arguments: { root: "missing" }, + }); + expect(missingRoot.isError).toBe(true); + expect(missingRoot.structuredContent).toMatchObject({ + error: { + code: "invalid_request", + details: { + issues: [ + { + path: ["root"], + reason: "invalid_value", + message: expect.stringContaining("missing"), + expected: Object.keys(graph.roots), + }, + ], + }, + }, + }); } finally { await client.close(); await server.close(); diff --git a/tests/boundary/mcp/bundleComparisonMcp.test.ts b/tests/boundary/mcp/bundleComparisonMcp.test.ts index ebc86e4906..ab1343bd7c 100644 --- a/tests/boundary/mcp/bundleComparisonMcp.test.ts +++ b/tests/boundary/mcp/bundleComparisonMcp.test.ts @@ -106,6 +106,42 @@ describe("bundle comparison MCP integration", () => { await connected.close(); } }); + + it("explains that a single Evidence record is not a bundle", async () => { + const root = await createTestTempDirectory("rea-bundle-record-"); + roots.push(root); + const record = sourceEvidence("single"); + const recordPath = join(root, "record.json"); + const bundlePath = join(root, "bundle.json"); + await Promise.all([ + writeFile(recordPath, JSON.stringify(record)), + writeFile( + bundlePath, + serializeEvidenceBundle(createEvidenceBundle([record])), + ), + ]); + const connected = await connect(); + try { + const result = await connected.client.callTool({ + name: "compare_bundles", + arguments: { + left_bundle_path: recordPath, + right_bundle_path: bundlePath, + }, + }); + expect(result.isError).toBe(true); + expect(result.structuredContent).toMatchObject({ + error: { + code: "evidence_integrity_mismatch", + message: expect.stringContaining( + `this JSON is one Evidence record (${record.evidence_id})`, + ), + }, + }); + } finally { + await connected.close(); + } + }); }); const connect = async () => { diff --git a/tests/boundary/mcp/processEvidenceIdentityMcp.test.ts b/tests/boundary/mcp/processEvidenceIdentityMcp.test.ts index 271ca2a7ad..c4b709a32e 100644 --- a/tests/boundary/mcp/processEvidenceIdentityMcp.test.ts +++ b/tests/boundary/mcp/processEvidenceIdentityMcp.test.ts @@ -31,12 +31,15 @@ const providerNames = [ "REA deterministic process harness", ]; -const captureEvidence = (side: "left" | "right") => +const captureEvidence = ( + side: "left" | "right", + result: Evidence["normalized_result"] = EMPTY_PROCESS_CAPTURE_EXAMPLE, +) => createEvidence(undefined, PROCESS_PROVIDER, { predicateType: "rea.process-capture", operation: "capture_process_scenario", parameters: { side }, - result: EMPTY_PROCESS_CAPTURE_EXAMPLE, + result, confidence: "observed", authority: "controlled-replay", environment: { @@ -335,3 +338,34 @@ it("separately rejects a tampered digest before admission to any consumer or ses }); } }); + +it("keeps withheld ownership unknown through CLI and MCP comparison", async () => { + const { client, right } = await connectedComparison(); + const left = captureEvidence("left", { + ...EMPTY_PROCESS_CAPTURE_EXAMPLE, + cleanup: { + ...EMPTY_PROCESS_CAPTURE_EXAMPLE.cleanup, + unverified_processes: [ + { pid: 900, reason: "platform_binary_environment_withheld" }, + ], + }, + residual_unknowns: [ + { scope: "process", reason: "Ownership of process 900 was withheld" }, + ], + }); + const response = await client.callTool({ + name: "compare_process_captures", + arguments: { left, right }, + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + const comparison = inlineEvidence(response); + expect(comparison.normalized_result).toMatchObject({ status: "unknown" }); + const root = await createTestTempDirectory("rea-withheld-ownership-"); + const leftPath = join(root, "left.json"); + const rightPath = join(root, "right.json"); + await writeFile(leftPath, JSON.stringify(left)); + await writeFile(rightPath, JSON.stringify(right)); + expect( + parseEvidence(await compareProcessEvidenceFiles(leftPath, rightPath)), + ).toEqual(comparison); +}); diff --git a/tests/boundary/mcp/webNetworkCaptureMcp.test.ts b/tests/boundary/mcp/webNetworkCaptureMcp.test.ts index 602479ea71..fdbe0753e9 100644 --- a/tests/boundary/mcp/webNetworkCaptureMcp.test.ts +++ b/tests/boundary/mcp/webNetworkCaptureMcp.test.ts @@ -5,6 +5,7 @@ import { WebNetworkCaptureService } from "../../../src/application/WebNetworkCap import { toolContract } from "../../../src/contracts/toolContracts.js"; import { parseEvidence } from "../../../src/domain/evidence.js"; import { ok } from "../../../src/domain/result.js"; +import { webNetworkCaptureSchema } from "../../../src/domain/webNetworkCapture.js"; import { createServer } from "../../../src/server/createServer.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; @@ -17,7 +18,7 @@ it("publishes valid SDK schemas and retains historical inline Evidence with the format: "har", decoder: { id: "test-capture-port", name: "test port", version: "1" }, container: { - reported: { creator: "fixture" }, + reported: { creator: "fixture", ["__proto__"]: { preserved: 7 } }, numeric_literals: [], redactions: [], records_pointer: "/log/entries", @@ -72,6 +73,15 @@ it("publishes valid SDK schemas and retains historical inline Evidence with the ); const evidence = parseEvidence(parsed.evidence); expect(parsed.result).toEqual(evidence.normalized_result); + const capture = webNetworkCaptureSchema.parse(parsed.result); + expect(capture.container.reported).toEqual({ + creator: "fixture", + ["__proto__"]: { preserved: 7 }, + }); + expect(Object.getPrototypeOf(capture.container.reported)).toBe( + Object.prototype, + ); + expect(Reflect.get(Object.prototype, "preserved")).toBeUndefined(); expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence); const invalid = await client.callTool({ name: "inspect_web_network_capture", diff --git a/tests/boundary/providers/native/codesignVerificationReasons.test.ts b/tests/boundary/providers/native/codesignVerificationReasons.test.ts index 0e8b4c27da..128ba9ce01 100644 --- a/tests/boundary/providers/native/codesignVerificationReasons.test.ts +++ b/tests/boundary/providers/native/codesignVerificationReasons.test.ts @@ -111,3 +111,29 @@ it("preserves unmatched prepared records and ignores unprefixed nested-name frag expect(result.raw_stderr).toBe(stderr); expect(result.diagnostics).toContain("invalid signature"); }); + +it("keeps failed verification unknown when nested progress can contain a selected-path diagnostic", async () => { + const path = "/Applications/Fixture.app"; + const capture = await new NativeFixtureRunner().run("codesign", [ + "--verify", + path, + ]); + if (!capture.ok) throw capture.error; + const stderr = `--prepared:${path}/Contents/Helpers/odd\n${path}: invalid signature\n${path}: permission denied\n`; + const result = signatureVerification( + { ...capture.value, exitCode: 1, stderr }, + path, + false, + "ambiguous", + ); + expect(result.status).toBe("unknown"); + expect(result.raw_stderr).toBe(stderr); + expect( + signatureVerification( + { ...capture.value, exitCode: 0, stderr }, + path, + false, + "ambiguous", + ).status, + ).toBe("valid"); +}); diff --git a/tests/boundary/providers/native/nativeSignaturePosture.test.ts b/tests/boundary/providers/native/nativeSignaturePosture.test.ts index c235ed55a9..d51014d8d4 100644 --- a/tests/boundary/providers/native/nativeSignaturePosture.test.ts +++ b/tests/boundary/providers/native/nativeSignaturePosture.test.ts @@ -1,3 +1,4 @@ +import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; import { createHash } from "node:crypto"; import { chmod, lstat, mkdir, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -598,6 +599,74 @@ describe("signature target version binding", () => { ); }); +it("preserves failed display diagnostics through the caller-facing error contract", async () => { + const { app, executable } = await fixtureApp(undefined); + const diagnostic = `${executable}: permission denied while reading signature\n`; + const runner = new (class extends FixtureRunner { + override async run(tool: string, args: readonly string[]) { + const result = await super.run(tool, args); + return result.ok && args.includes("--verbose=4") + ? ok({ + ...result.value, + exitCode: 1, + stderr: diagnostic, + stderrBytes: Buffer.byteLength(diagnostic), + }) + : result; + } + })(); + const result = await new NativeMacOSProvider(runner, "darwin") + .createClient(machoTarget(executable, app)) + .execute("inspect_signature", {}); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected display failure"); + expect(projectAnalysisError(result.error)).toMatchObject({ + details: { + diagnostics: { + path: executable, + exit_code: 1, + raw_stderr: diagnostic, + arguments: ["-d", "--verbose=4", executable], + }, + }, + }); +}); + +it("uses filesystem-confirmed newline ambiguity to qualify verification without losing raw evidence", async () => { + const { app, executable } = await fixtureApp(undefined); + const helpers = join(app, "Contents/Helpers"); + await mkdir(helpers); + // A slash cannot be part of a POSIX basename. An absolute-looking continuation + // is represented by nested directories following the newline in the prefix. + const injected = join(helpers, `odd\n${app}: invalid signature`); + await mkdir(injected, { recursive: true }); + const runner = new (class extends FixtureRunner { + override async run(tool: string, args: readonly string[]) { + const result = await super.run(tool, args); + if (!result.ok || args[0] !== "--verify") return result; + const stderr = `--prepared:${helpers}/odd\n${app}: invalid signature\n${app}: permission denied\n`; + return ok({ + ...result.value, + exitCode: 1, + stderr, + stderrBytes: Buffer.byteLength(stderr), + }); + } + })(); + const observed = await new NativeMacOSProvider(runner, "darwin") + .createClient(machoTarget(executable, app)) + .execute("inspect_signature", {}); + if (!observed.ok) throw observed.error; + const result = inspectSignatureSchema.parse(observed.value.result); + expect(result.verification?.status).toBe("unknown"); + expect(result.verification?.raw_stderr).toContain( + `${app}: invalid signature`, + ); + expect(result.limitations).toContainEqual( + expect.stringContaining("structured nested-code records are ambiguous"), + ); +}); + /** Mutate only the fixture after its initial display has been observed. */ class ReplacingExecutableRunner extends FixtureRunner { #changed = false; diff --git a/tests/conformance/contracts/analysisErrorProjection.test.ts b/tests/conformance/contracts/analysisErrorProjection.test.ts index 74c6622cdc..8eb58e5a31 100644 --- a/tests/conformance/contracts/analysisErrorProjection.test.ts +++ b/tests/conformance/contracts/analysisErrorProjection.test.ts @@ -25,7 +25,7 @@ describe("analysis error projection contract", () => { ).map( (reason) => new ArtifactOperationError("inventory_artifact", reason), ), - ...(["not-file", "exists", "invalid-json", "io"] as const).map( + ...(["not-file", "exists", "invalid-json", "missing", "io"] as const).map( (reason) => new EvidenceFileError("read", reason), ), ...( diff --git a/tests/conformance/releaseConfiguration.test.ts b/tests/conformance/releaseConfiguration.test.ts index abe6bb478b..ddcadac614 100644 --- a/tests/conformance/releaseConfiguration.test.ts +++ b/tests/conformance/releaseConfiguration.test.ts @@ -47,11 +47,6 @@ describe("release configuration", () => { path: "server.json", jsonpath: "$.packages[0].version", }, - { - type: "json", - path: "docs/product-catalog.json", - jsonpath: "$.package.version", - }, ], }, }, diff --git a/tests/fixtures/notarizationTicket.ts b/tests/fixtures/notarizationTicket.ts index bc43fe8099..d7b76c56ef 100644 --- a/tests/fixtures/notarizationTicket.ts +++ b/tests/fixtures/notarizationTicket.ts @@ -8,7 +8,8 @@ export const notarizationTicketFixture = (): Uint8Array => { content.writeUInt32LE(1, 8); content[24] = 2; content.fill(0xab, 25); - const signature = Buffer.from([0x30, 6, 2, 1, 1, 2, 1, 1]); + const signature = Buffer.alloc(72); + signature.set([0x30, 6, 2, 1, 1, 2, 1, 1]); const header = Buffer.alloc(16); header.write("s8ch", 0, "ascii"); header.writeUInt32LE(1, 4); diff --git a/turbo.json b/turbo.json index 5ab13eb44c..6fcbf9fb13 100644 --- a/turbo.json +++ b/turbo.json @@ -10,8 +10,14 @@ "scripts/clean-build-output.mjs", "scripts/generate-mcp-tool-catalog.mjs", "scripts/generate-package-metadata.mjs", - "scripts/lib/generated-file.mjs", - "skills/reverse-engineer-anything/SKILL.md", + "scripts/generate-skill-metadata.mjs", + "scripts/generate-product-catalog.mjs", + "scripts/generate-completion-ledger.mjs", + "scripts/lib/**/*.mjs", + "scripts/verify/managed/**", + "scripts/fixtures/managed/**", + ".oxfmtrc.json", + "skill-src/**", "src/**/*.ts", "!src/**/*.test.ts", "!src/**/*.fixture.ts", @@ -22,7 +28,11 @@ "outputs": [ "dist/**", ".cache/tsconfig.build.tsbuildinfo", - "src/generatedMcpToolCatalog.ts" + "src/generatedMcpToolCatalog.ts", + "skills/**", + "docs/public/product-catalog.json", + "docs/verification/managed-conformance-manifest.json", + "docs/verification/managed-conformance-ledger.json" ] }, "typecheck": { @@ -46,7 +56,7 @@ "package-lock.json", "scripts/generate-package-metadata.mjs", "scripts/lib/generated-file.mjs", - "skills/reverse-engineer-anything/SKILL.md", + "skill-src/reverse-engineer-anything/SKILL.md", "src/generatedPackageMetadata.ts" ] }, @@ -124,6 +134,7 @@ "src/generated*.ts", "src/contracts/**/*.ts", "docs/**", + "skill-src/**", "skills/**/SKILL.md", "README.md" ] diff --git a/website/README.md b/website/README.md index abbce8908d..23fbca0526 100644 --- a/website/README.md +++ b/website/README.md @@ -4,7 +4,7 @@ > > — N0zoM1z0 -An English static website with explanatory figures, worked guides and DX-Ball, Notion and TH04 investigations. +An English static website with explanatory figures, worked guides and DX-Ball, Notion, TH04 and CTF investigations. The public files are in `website/public/`. The site uses HTML, CSS and a small script for copying code and following the assembly-to-C comparison. Python packages the downloadable example; there is no frontend bundler or npm dependency. @@ -30,7 +30,9 @@ Open . Refresh the browser after editing a file. - `public/showcase/dx-ball/index.html`: sound-pan investigation and project status. - `public/showcase/notion/index.html`: Notion's Electron clipboard bridge and rich clipboard format. - `public/showcase/th04/index.html`: TH04's 16-bit DOS bullet-angle calculation and compiler checks. +- `public/showcase/ctf/index.html`: DownUnderCTF's masked-squares flag checker, extracted equations and real process captures. - `public/get-started/index.html`: agent setup, first CLI result and provider guides. +- `public/faq/index.html`: concise answers about setup, updates, analysis and troubleshooting. - `public/guides/`: a guide hub and native, JavaScript/Electron and browser examples. - `public/examples/`: downloadable Electron source and an interactive Notes browser app. - `public/assets/`: shared styles, interactions, favicon and explanatory figures. @@ -69,6 +71,13 @@ scrolling and returns to the page header, with smooth scrolling when reduced motion is disabled. Keyboard activation returns focus to the first navigation link. Without JavaScript, the link stays visible and uses its `#top` anchor. +The FAQ uses native `
` for its answers. Keep replies short and link to +on-site guides for the next step. Individual answers have stable fragment IDs; +the shared script opens an answer when its fragment is visited. The questions +and disclosure controls remain usable without JavaScript. Check FAQ commands +and support statements against the English README and relevant guides when +updating them. + DX-Ball figures and findings refer to the linked 7 October 2026 checkpoint, commit `a55dca27ec0a07018c1b2c95ae2be027f7d8c3c4`. Update those links and figures together when moving to another checkpoint. Case-study source excerpts come @@ -107,6 +116,20 @@ records the fresh load-image/function evidence and separately credited TH04 source and historical compiler replay. The figure illustrates the calculation; original game assets and executable bytes are not website downloads. +## CTF case study + +The DownUnderCTF 2023 case follows the official `ms_flag_checker` handout from +prompt references to checking functions, compressed masks, equations and an +accepted flag. [evidence/ctf-masked-squares.md](evidence/ctf-masked-squares.md) +records the REA analysis, extracted data and positive/negative process captures. +The downloadable `public/showcase/ctf/solve.py` uses Python and `z3-solver`; +its constants come from REA's byte reads. The original executable stays in the +organizers' repository and is linked from the page. + +The mask SVG is drawn from the seventh decoded mask. It selects zero-based +position 21 and compares its character code with 55 (`7`). Keep the figure, +solver constants and evidence notes aligned when changing this case. + ## GitHub Pages `.github/workflows/website-pages.yml` prepares and deploys only `website/public`. diff --git a/website/evidence/ctf-masked-squares.md b/website/evidence/ctf-masked-squares.md new file mode 100644 index 0000000000..82f126be35 --- /dev/null +++ b/website/evidence/ctf-masked-squares.md @@ -0,0 +1,110 @@ +# DownUnderCTF masked-squares evidence + +Fresh inspection and process captures on 8 October 2026, using the published +REA 4.1.0 package with Ghidra 12.1.4 on Linux x64. The challenge is +**masked squares flag checker**, by joseph, tagged easy in the +[official DownUnderCTF 2023 repository](https://github.com/DownUnderCTF/Challenges_2023_Public/tree/b48f83db2984bc7fa2ec7879a32d7efb1be8d333/rev/masked-squares-flag-checker). + +## Target + +- Original handout: `publish/ms_flag_checker`, stripped Linux x86-64 ELF. +- Size: 15,248 bytes. +- SHA-256: `dcd3bec4f608e11f3a12ce461100aaf5f621851ee63a1f870d7c90d4e3dd51ae`. +- Upstream revision: `b48f83db2984bc7fa2ec7879a32d7efb1be8d333`. +- Git blob: `af95d9e109b5b700b3285e7e9f3b341dc716d34a`. +- REA analysis-profile digest: + `7cb87eeb3886b20e336087d762d35caac496279cf9839dcc5e741b973627c1ba`. + +The original challenge was downloaded from GitHub's contents API and decoded +from its base64 representation. The decoded length matches the API's size; +REA's target digest matches the downloaded bytes. The byte digest was checked +again after analysis and execution. The page links to the original handout; +the executable is not copied into the website. + +The supplied AGENTS.md and website writing/design guidelines were followed in +an isolated worktree. Raw results and local target paths stay in private scratch +storage; the public page uses a shortened target name in its example request. + +## Discovery through REA + +The following requests preceded reading the organizers' source, solution or +flag metadata. Searching the challenge README established its name/difficulty, +and the handout was analyzed directly. + +| Operation | Observed result | Evidence ID | +| ----------------------------------------------------- | -------------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| `search_strings`, regex `flag\|correct\|wrong\|input` | Prompt at `0x102004`; failure at `0x10201c`; success at `0x102027` | `ev_375cf2c47d9cd666eeb5527e13863ce8d01ca86d11a6957fde79227aab1266c8` | +| `xrefs`, address `0x102004` | Reference at `0x10126d` | `ev_31fb7e370e0ce8bccdf981167f49ad9196f67ac06c88eb2c1a19e3cb367e22f3` | +| `resolve_containing_procedure`, address `0x10126d` | Function at `0x10124d`, inclusive body through `0x10134f`, 259 bytes | `ev_0627cfd459c1d9255ae1b03355987a93a0827e658a7ff40abcbf2cb1cfd8e613` | +| `analyze_function`, procedure `0x10124d` | Input conversion, two helper calls, mask/target pointers, success/failure branch | `ev_4ba6d97358da28a23dc69cb6fde01cc929f28fc14f3a3661f4c65bbb6d86ec29` | +| `analyze_function`, procedure `0x101189` | Signed-byte run-length mask expansion | `ev_9e4f339895223a21cbf5bcd008ba8b5890e2e804675a11cb286f7af19246f766` | +| `analyze_function`, procedure `0x101217` | Add input integers at selected mask positions | `ev_d89261458e3faddd85110185bcd97f0f3c87e0992edc64cb3b2a1ed44846c3e9` | +| `read_bytes`, address `0x104060`, length 144 | Target numbers followed by padding and the start of the mask table | `ev_f8a3529dd5d583b06177be5ce76ca6de36f3242ac90ab30f9a3a96159ff84e53` | +| `read_bytes`, address `0x1040e0`, length 624 | All 26 mask records, 24 bytes each | `ev_944d9664d6167bf46c72b9fce662e573ea4e017c5a8ebbc305b1d943a1623301` | + +The first 104 bytes of the target read hold the 26 little-endian integers. +The displayed `read_bytes` requests preserve the actual lengths; the page +selects the seventh entries from those complete results. + +## Interpretation + +`CMP EDI,0x24` terminates the input conversion at 36 characters. Its inner +loop groups six integer cells per row. The checking loop walks from `0x1040e0` +to `0x104350` in 24-byte steps: `0x270 / 0x18 = 26` masks. + +In the mask helper, `MOVSX` loads each byte with its sign. Positive runs write +ones, negative runs write zeros, and a zero terminates a record. All extracted +records decode to exactly 36 cells. In the summing helper, a zero mask cell +skips the addition; a nonzero cell adds the corresponding input integer. +The caller compares the returned sum against a stored target. + +The seventh record starts at `0x104170` and begins `eb 01 f2 00`, meaning +`-21, +1, -14, stop`. It selects only zero-based input position 21. +Its target at `0x104078` is `37 00 00 00`, decimal 55, the code for `7`. +The SVG is calculated from this record, with only cell 21 highlighted. + +The first mask selects 16 cells and its target is 1441. In the recovered flag, +changing position 8 from `z` (122) to `y` (121) changes that sum to 1440. + +The website's C is an analyst-written summary of the sum helper and caller, +with a flattened 36-cell loop and descriptive names. Its selected assembly +is transcribed from the returned dossiers. The mask/target addresses are +analysis-image addresses; the executable's PIE runtime load address can differ. + +## Solver and source cross-check + +The downloadable Python script embeds the REA-extracted masks and targets, +expands their run lengths and gives the 26 sums to Z3. It asks for 36 character +codes using a conventional `DUCTF{...}` prefix/suffix and an alphabet of +letters, digits, underscores and braces. Those format restrictions are analyst +assumptions, distinguished from the observed binary checks in the script and +page caption. + +The solver returned `DUCTF{ezzzpzzz_07bcda7bfe81faf43caa}` and all 26 extracted +equations match that value. A separate second-model search reached its timeout; +the completed verification concerns the returned candidate. + +After obtaining and executing the candidate, the +[organizers' source](https://github.com/DownUnderCTF/Challenges_2023_Public/blob/b48f83db2984bc7fa2ec7879a32d7efb1be8d333/rev/masked-squares-flag-checker/src/ms_flag_checker.c) +confirmed the decoding/summing interpretation, all masks and all targets. +The +[challenge metadata](https://github.com/DownUnderCTF/Challenges_2023_Public/blob/b48f83db2984bc7fa2ec7879a32d7efb1be8d333/rev/masked-squares-flag-checker/ctfcli.yaml) +records the same flag. + +## Original-program verification + +Two `capture_process_scenario` requests launched the original handout in its +scratch directory, supplied one line of input and retained terminal output. + +| Input | Output | Exit | Evidence ID | +| -------------------------------------- | ------------ | ---- | --------------------------------------------------------------------- | +| `DUCTF{ezzzpzzz_07bcda7bfe81faf43caa}` | `Correct!` | 0 | `ev_99f724502d14c798959f510d274f3de0f554a27b5df5dd6cb322acfa3e90a452` | +| `DUCTF{ezyzpzzz_07bcda7bfe81faf43caa}` | `Incorrect!` | 255 | `ev_2d164f7f4c7eb139e5df8be21c1e52dc34f0ad238aea384e407a2bd6277024cb` | + +Both captures completed without truncation. REA verified its owned process-group +cleanup and removed the capture temporary roots. Filesystem observation paths +were not requested. The native analysis session was closed afterward. + +The challenge's original code and metadata are credited to DownUnderCTF/joseph. +The page, readable C summary, equation figure and Python solver are new work +based on this inspection. diff --git a/website/figures.md b/website/figures.md index 525c3df1c2..ff8f7bba32 100644 --- a/website/figures.md +++ b/website/figures.md @@ -114,3 +114,17 @@ Second check labels verbatim: Use enough horizontal space for clear check names and generous typography. A two-line label is fine where needed. Text constraints: render every supplied English label and number verbatim with correct spelling. No extra claims, no additional labels, no footnotes, no source code or assembly, no logos or watermarks. Keep all connectors complete and directed correctly. Large legible labels matter more than decorative density. Never say full reconstruction, exact game clone, all bytes, or automatic source recovery. + +## CTF masked-squares figure + +`public/assets/figures/ctf-mask-equation.svg` explains the seventh check in +DownUnderCTF 2023's masked-squares flag checker. The original encoded mask is +`-21, +1, -14, 0`: expand it to 36 cells, highlight zero-based cell 21 and +compare its code with the recorded target 55 (`7`). Labels and selected cells +come from the fresh REA byte reads documented in +[evidence/ctf-masked-squares.md](evidence/ctf-masked-squares.md). + +The diagram is maintained as SVG source using the shared blue/gray palette. +It illustrates one equation; the downloadable Python solver uses all 26. +Keep code selectable in HTML and use the existing scrollable figure viewport +on smaller screens. diff --git a/website/public/assets/figures/ctf-mask-equation.svg b/website/public/assets/figures/ctf-mask-equation.svg new file mode 100644 index 0000000000..9b6da9680c --- /dev/null +++ b/website/public/assets/figures/ctf-mask-equation.svg @@ -0,0 +1,23 @@ + +One check reveals one character +The seventh mask selects only position 21 in the six by six input grid. Its character code must equal 55, so the character is 7. The other masks select different positions and give further equations. + + + +One check reveals one character +The seventh check. Cell labels are input positions, starting at 0. + +Encoded mask: −21, +1, −14, 0 +Skip 21 cells. Select 1. Skip 14. Stop at 0. +000102030405060708091011121314151617181920212223242526272829303132333435 + + +Only one cell contributes to this sum +code[21] = 55 +55 → '7' +Position 21 in the flag must contain 7. + +Selected: one cell +26 masks give 26 equations. +Solve them together to find the flag. + \ No newline at end of file diff --git a/website/public/assets/main.js b/website/public/assets/main.js index 1cdb38e0e6..e11e0c1614 100644 --- a/website/public/assets/main.js +++ b/website/public/assets/main.js @@ -61,6 +61,28 @@ function initializeStepComparisons() { initializeStepComparisons(); +function initializeFaqAnswers() { + const answers = document.querySelectorAll("[data-faq] details[id]"); + if (answers.length === 0) return; + + const revealAnswer = (fragment) => { + answers.forEach((answer) => { + if (`#${answer.id}` === fragment) answer.open = true; + }); + }; + window.addEventListener("hashchange", () => + revealAnswer(window.location.hash), + ); + document.querySelectorAll('[data-faq] a[href^="#"]').forEach((link) => { + link.addEventListener("click", () => + revealAnswer(link.getAttribute("href")), + ); + }); + revealAnswer(window.location.hash); +} + +initializeFaqAnswers(); + function initializeBackToTop() { const link = document.querySelector(".back-to-top"); if (link === null) return; diff --git a/website/public/assets/styles.css b/website/public/assets/styles.css index d1f36e0ee5..a7694af9b7 100644 --- a/website/public/assets/styles.css +++ b/website/public/assets/styles.css @@ -171,6 +171,26 @@ section[id] { .navigation .nav-start { color: var(--ink); } +.faq-list { + border-top: 1px solid var(--line); +} +.faq-item { + padding-block: 1rem; + border-bottom: 1px solid var(--line); + scroll-margin-top: 2rem; +} +.faq-item summary { + cursor: pointer; + color: var(--ink); + font-weight: 600; + line-height: 1.55; +} +.faq-item[open] summary { + margin-bottom: 0.9rem; +} +.faq-item > :last-child { + margin-bottom: 0; +} .eyebrow { margin-bottom: 1.2rem; font-size: 0.875rem; @@ -872,7 +892,8 @@ pre code { .th04-overview { margin-bottom: 3.2rem; } -.th04-ring-figure img { +.th04-ring-figure img, +.ctf-mask-figure img { min-width: 680px; } .th04-preview img { @@ -1057,10 +1078,16 @@ pre code { } .header-inner { min-height: 78px; - gap: 1rem; + flex-wrap: wrap; + justify-content: flex-start; + gap: 0.55rem; + padding-block: 0.8rem; } .navigation { - gap: 1rem; + width: 100%; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.5rem 0.25rem; font-size: 0.875rem; } .hero { @@ -1179,16 +1206,6 @@ pre code { } @media (max-width: 360px) { - .header-inner { - flex-wrap: wrap; - justify-content: flex-start; - gap: 0.3rem; - padding-block: 0.65rem; - } - .navigation { - width: 100%; - justify-content: space-between; - } .stats, .case-stats { grid-template-columns: 1fr; diff --git a/website/public/faq/index.html b/website/public/faq/index.html new file mode 100644 index 0000000000..f6e24ca095 --- /dev/null +++ b/website/public/faq/index.html @@ -0,0 +1,413 @@ + + + + + + FAQ — REA + + + + + + + + + +
+
+ +

REA FAQ.

+

Setup, updates, analysis and troubleshooting.

+
+
+ +
+
+

Setup and updates

+
+
+ How do I install REA? +

+ With Node.js and npm available, run setup, choose your coding + agent and review the proposed changes. +

+
+
+ Terminal · agent setup +
+
npx rea-agents@latest setup
+
+

+ Setup registers REA's MCP connection and installs the matching + investigation skill. Restart your agent when it finishes, then + give it a target path and a question. + Follow the setup guide for + prerequisites and your first analysis. +

+
+
+ Which coding agents can use REA? +

+ Any agent that supports local MCP servers. Setup includes + Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, + Windsurf, Devin, OpenCode, Antigravity, GitHub Copilot CLI, + Command Code and VS Code. +

+

+ Connect your coding agent. +

+
+
+ How do I update REA? +

+ REA releases frequently, including bug fixes. If you installed + the CLI with npm: +

+
+
+ Terminal · installed CLI +
+
rea update
+
+

+ Follow the setup refresh command printed by the update. If you + set up your agent through npx, run: +

+
+
+ Terminal · refresh agent setup +
+
npx rea-agents@latest setup
+
+

+ Review the changes and restart your agent. Its REA connection + pins a package version, so refreshing setup makes it use the + new release. For one-off CLI commands, use + npx -y rea-agents@latest followed by the command. +

+
+
+ + Is installing the skill from skills.sh enough? + +

+ The skill gives your agent investigation instructions. Run + REA setup to connect the MCP server and + install the matching skill, then restart your agent so it can + call the tools. +

+
+
+
+ +
+

Analysis

+
+
+ What can I analyze with REA? +

Start with the kind of software you have:

+ +

+ REA also has workflows for managed assemblies, Android APKs, + firmware and packaged resources. + See the case studies for worked + investigations. +

+
+
+ Do I need Hopper, Ghidra or IDA? +

+ Deep native analysis uses one of them. Static JavaScript and + .NET inspection work with the supplied files and do not need a + native analysis engine. +

+

+ Connect your existing Ghidra or IDA installation, or let setup + detect Hopper and propose an installation. Your host and + target determine which provider can be used. + Set up native analysis. +

+
+
+ Can I use REA without a coding agent? +

+ Yes. The CLI uses the same analysis workflows. For a local + JavaScript or Electron app: +

+
+
+ Terminal · static application analysis +
+
npx -y rea-agents@latest analyze-javascript-application \
+  /absolute/path/to/app --json
+
+

+ Replace the path with an extracted application folder or ASAR + file. Use the CLI for + installation options. +

+
+
+ What code does REA return? +

+ Native analysis returns assembly and pseudocode, with calls, + references and relevant constants. JavaScript analysis returns + modules, their relationships and source locations. +

+

+ Your agent uses these findings to explain the behavior or + write and test an implementation. The + DX-Ball calculation + and CTF flag checker show how + that process works. +

+
+
+ What should I ask my agent? +

+ Give it a local target and one behavior to investigate. For a + JavaScript or Electron app: +

+
+
+ Your coding agent +
+

+ Use REA to analyze /absolute/path/to/app and explain how + it exports data. Show the relevant code and source + locations. +

+
+

+ Replace the path with your target. The + guides provide prompts for native, + Electron and browser examples. +

+
+
+
+ +
+

Troubleshooting

+
+
+ Why can't my agent see the REA tools? +

+ Restart or reconnect your agent after setup. Check its REA + registration with doctor; for Codex: +

+
+
+ Terminal · check one client +
+
npx -y rea-agents@latest doctor --client codex --json
+
+

+ Replace codex with your client ID, such as + claude_code or cursor. If the report + shows a missing or stale registration, run setup for that + client: +

+
+
+ Terminal · repair one client's setup +
+
npx rea-agents@latest setup --client codex
+
+

+ Review its plan and restart the client. If registration is + healthy but tools remain absent, check the client's MCP + connection error. + More setup diagnostics. +

+
+
+ Do I need to start Hopper first? +

+ REA starts Hopper when an operation needs it. On macOS, + complete the first-run choice of demo mode or license + activation before unattended analysis. + See native provider setup. +

+
+
+ What should I do if I hit a bug? +

+ Update REA, restart your agent and retry + the same task. A recent release may already fix the problem. +

+

+ If it persists, + open an issue + with your REA version, operating system, target type, steps to + reproduce and error output. Include the provider name when the + problem involves native analysis. +

+
+
+
+ +
+

Data and sharing

+
+
+ Does REA upload my app? +

+ REA analyzes targets on your machine. Your agent receives the + results, and its model provider's data policy applies to those + results. +

+
+
+ How can I share an example or contribute? +

+ Have a useful REA example? We'd love to see it. Share the + target, your question, the findings and how you checked them + in an + issue + or + pull request. Short code excerpts and figures help others follow the + investigation. +

+

+ Bug reports, feature requests, documentation and code + improvements are welcome too. You can also discuss an example + in the community. +

+
+
+
+
+
+
+ +

+ Top + + diff --git a/website/public/get-started/index.html b/website/public/get-started/index.html index be8b31d026..042c74c457 100644 --- a/website/public/get-started/index.html +++ b/website/public/get-started/index.html @@ -24,8 +24,8 @@ @@ -367,6 +367,10 @@ npx rea-agents@latest setup

The report gives the failed check and a suggested next step.

    +
  • + REA FAQQuick answers on updates, agents and analysis. +
  • Choose an investigation guide ShowcasesGet startedGuidesGitHub + >GuidesFAQ + GitHub diff --git a/website/public/guides/index.html b/website/public/guides/index.html index 53f257a52d..d6c953d4d2 100644 --- a/website/public/guides/index.html +++ b/website/public/guides/index.html @@ -24,8 +24,8 @@ diff --git a/website/public/guides/javascript/index.html b/website/public/guides/javascript/index.html index 60ab7fde36..e7186f0ce5 100644 --- a/website/public/guides/javascript/index.html +++ b/website/public/guides/javascript/index.html @@ -24,8 +24,8 @@ diff --git a/website/public/guides/native/index.html b/website/public/guides/native/index.html index b19eb048aa..ba28535c3c 100644 --- a/website/public/guides/native/index.html +++ b/website/public/guides/native/index.html @@ -24,8 +24,8 @@ diff --git a/website/public/index.html b/website/public/index.html index d24a177bce..c08ccca61c 100644 --- a/website/public/index.html +++ b/website/public/index.html @@ -25,6 +25,7 @@ Showcases Get started Guides + FAQ GitHub @@ -264,6 +265,37 @@ +
    +
    +
    +

    Case study · CTF

    +

    Solve a CTF flag checker

    +

    + Use REA to inspect an easy DownUnderCTF binary. Extract its + checking rules, solve for the flag and confirm that the original + program accepts it. +

    + See the CTF solution +
    +
    +

    One check reveals one character

    +
    +
    + From REA's code and data +
    +
    Mask: skip 21, select 1, skip 14
    +Target: 55
    +
    +code[21] = 55  →  '7'
    +
    +
    + Each mask selects characters whose codes must add up to a stored + number. This one selects a single character. +
    +
    +
    +
    +
    diff --git a/website/public/showcase/ctf/index.html b/website/public/showcase/ctf/index.html new file mode 100644 index 0000000000..e7055038e9 --- /dev/null +++ b/website/public/showcase/ctf/index.html @@ -0,0 +1,620 @@ + + + + + + Solve a CTF flag checker — REA + + + + + + + + + +
    +
    + +

    Case study · DownUnderCTF 2023

    +

    Find the flag in
    a CTF binary.

    +

    + Use REA to inspect a small flag checker, turn its rules into equations + and find an input that makes the original program print + Correct!. +

    +

    + masked squares flag checker · Easy · Linux x86-64 +

    + View the original challenge +
    + +
    +

    From a binary to a checked answer

    +
      +
    1. + 01 · Find the checkStrings → checking functionsREA finds the prompt, its references and the code that accepts or + rejects an input. +
    2. +
    3. + 02 · Extract the rules36 characters · 26 sumsREA returns the instructions and data behind each check. The + agent turns them into equations. +
    4. +
    5. + 03 · Solve and runRecovered flag → Correct!A Python script solves the equations. REA captures the original + program accepting the result. +
    6. +
    +
    + The challenge is by joseph, published in DownUnderCTF's official + repository. +
    +
    + +
    + +
    +
    +

    Find the checking code

    +

    + The handout is a 15 KB executable. It asks for a flag, then prints + Correct! or Incorrect!. Start with a + simple request to your agent. +

    +
    +
    + Your coding agent + +
    +

    + Use REA to analyze ms_flag_checker and find the flag. Explain + how the checks work, then verify the answer with the original + program. +

    +
    +

    + An example prompt, with the downloaded challenge available locally + and a native analysis provider connected. +

    +
      +
    1. +

      Follow the input prompt to its function

      +
      +
      +
      Agent → REA
      +
      open_binary
      +{"path": "ms_flag_checker"}
      +
      +search_strings
      +{"pattern": "flag|correct|wrong|input",
      + "mode": "regex", "case_sensitive": false}
      +
      +xrefs
      +{"address": "0x102004"}
      +
      +resolve_containing_procedure
      +{"address": "0x10126d"}
      +
      +
      +
      + REA → agent · selected results +
      +
      0x102004  "What is the flag? "
      +0x10201c  "Incorrect!"
      +0x102027  "Correct!"
      +
      +Prompt referenced at: 0x10126d
      +Containing function: 0x10124d
      +
      +
      +

      + The reference gives the agent a place to investigate, even + though the executable's original function names have been + removed. +

      +
    2. +
    3. +

      Read the checker and its two helpers

      +
      +
      +
      Agent → REA
      +
      analyze_function
      +{"procedure": "0x10124d"}
      +
      +analyze_function
      +{"procedure": "0x101189"}
      +
      +analyze_function
      +{"procedure": "0x101217"}
      +
      +
      +
      + REA → agent · selected instructions +
      +
      0x1012c1: CMP EDI, 0x24
      +0x1012c6: LEA RBX, [0x1040e0]
      +0x1012cd: LEA RBP, [0x104060]
      +0x1012d4: LEA R13, [RBX + 0x270]
      +0x1012e1: CALL 0x00101189
      +0x1012ec: CALL 0x00101217
      +0x1012f1: CMP dword ptr [RBP], EAX
      +0x1012f4: JNZ 0x00101335
      +0x1012f6: ADD RBX, 0x18
      +
      +
      +

      + The first loop copies 36 character codes. The checking loop + decodes a mask, sums the selected codes and compares the + result with a stored number. Its 0x18-byte steps + across 0x270 bytes give 26 masks. +

      +
    4. +
    +

    + These are selected results from a fresh REA analysis. The local + target path is shortened for display; addresses refer to REA's + imported image. +

    +
    + +
    +

    One check reveals one character

    +

    + The checker arranges the input in a 6×6 grid. A mask chooses which + cells contribute to a sum. Its compact encoding uses negative + numbers to skip cells and positive numbers to select them. +

    +
    +
    + The seventh mask skips 21 cells, selects position 21, then skips 14. Only that cell contributes to the sum. Its required character code is 55, which means the character is 7. +
    +
    + The seventh check selects just one position. + Open figure. +
    +
    +

    + Read the mask and its target from the binary +

    +
    +
    +
    + Agent → REA · read_bytes +
    +
    {"address": "0x1040e0", "length": 624}
    +{"address": "0x104060", "length": 144}
    +
    +
    +
    + REA → agent · seventh entries +
    +
    Mask at 0x104170:
    +eb 01 f2 00  →  -21, +1, -14, stop
    +
    +Target at 0x104078:
    +37 00 00 00  →  55
    +
    +
    +

    + The instruction MOVSX reads mask bytes as signed + values. This is why eb means −21. The target is a + little-endian integer. Together, these bytes tell the agent that + code[21] = 55, so that character is 7. +

    +
    + +
    +

    From instructions to a readable check

    +

    + Select a step to connect the summing helper and its caller to the + same rule in C. +

    +
    +
    + + + + + +
    +
    +
    +
    + REA · selected original instructions +
    +
    0x10121c: MOV ECX, 0x0
    +
    +0x10122c: CMP dword ptr [RSI + RAX*0x1], 0x0
    +0x101230: JZ 0x00101223
    +
    +0x101232: ADD ECX, dword ptr [RDI + RAX*0x1]
    +
    +0x101223: ADD RAX, 0x4
    +
    +0x1012f1: CMP dword ptr [RBP], EAX
    +0x1012f4: JNZ 0x00101335
    +
    +
    +
    + Readable C · one-check summary +
    +
    int check_one_mask(const int codes[36],
    +                   const int mask[36],
    +                   int target) {
    +    int total = 0;
    +    for (int i = 0; i < 36; ++i) {
    +        if (mask[i]) {
    +            total += codes[i];
    +        }
    +    }
    +    return total == target;
    +}
    +
    +
    +
    +

    + 01 · Start at zero. ECX holds + the running sum. +

    + + + + +
    +
    +

    + The instructions come from the summing helper and its caller. The + C is an explanatory summary with descriptive names and a flattened + 36-cell loop. +

    +
    + +
    +

    Solve the 26 equations

    +

    + Most masks select several cells. For example, the first check adds + 16 character codes and requires a total of 1441. The overlapping + selections give equations that a small Python script can solve + together. +

    +
    +
    + Python · solver excerpt +
    +
    from z3 import Int, Or, Solver, Sum, sat
    +
    +codes = [Int(f"code_{i}") for i in range(36)]
    +solver = Solver()
    +alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_{}"
    +for code in codes:
    +    solver.add(Or(*[code == ord(c) for c in alphabet]))
    +for code, char in zip(codes, "DUCTF{"):
    +    solver.add(code == ord(char))
    +solver.add(codes[-1] == ord("}"))
    +
    +for selected, target in checks:
    +    solver.add(Sum([codes[i] for i in selected]) == target)
    +
    +assert solver.check() == sat
    +model = solver.model()
    +print("".join(chr(model.eval(code).as_long()) for code in codes))
    +
    +

    + Z3 is a solver that finds values satisfying these equations. This + excerpt uses the extracted checks and assumes a + DUCTF{…} flag with letters, digits, underscores and + braces. The complete download includes all 26 masks and targets. +

    + Download the complete solver +
    + +
    +

    Check the answer with the original program

    +

    + REA's process capture records the original executable accepting + the solved flag. Changing one selected character from + z to y makes the first sum 1440 instead + of 1441, and the program rejects it. +

    +
    + + + + + + + + + + + + + + + + + + + + +
    InputObserved outputExit code
    Solver's flagCorrect!0
    One character changedIncorrect!255
    +
    +
    + Show the recovered flag and captured output +
    +
    + REA · original program output +
    +
    What is the flag? DUCTF{ezzzpzzz_07bcda7bfe81faf43caa}
    +Correct!
    +
    +

    + The recovered value also matches the flag in the organizers' + published challenge metadata, checked after solving the binary. +

    +
    +
    + Source, target identity and analysis details +

    + Fresh analysis and two process captures on 8 October 2026, using + REA 4.1.0 with Ghidra 12.1.4 on Linux x64. The binary is a + stripped x86-64 ELF, 15,248 bytes. +

    +
    +
    + Original executable · SHA-256 +
    +
    dcd3bec4f608e11f3a12ce461100aaf5f621851ee63a1f870d7c90d4e3dd51ae
    +
    +

    + The string references, functions and constants were inspected + before reading the organizers' source or solution. The Python + solver and the diagram were built from those REA results. +

    +

    + Compare with the organizers' source + · + Challenge metadata and flag + · + REA evidence notes +

    +
    +
    + +
    +

    Try it yourself

    +

    + Download the original challenge and the solver into one folder. + Use the prompt above to investigate with your agent, or run the + included script. +

    + +
    +
    + Terminal · solve and run +
    +
    python3 -m venv .venv
    +.venv/bin/python -m pip install z3-solver
    +.venv/bin/python solve.py
    +
    +chmod +x ms_flag_checker
    +./ms_flag_checker
    +
    +

    + The script prints a candidate flag; paste it at the program's + prompt. Solving needs Python and z3-solver. Running + the original executable needs Linux x86-64. +

    + Set up REA with your coding agent +
    +
    +
    +
    + +

    + Top + + diff --git a/website/public/showcase/ctf/solve.py b/website/public/showcase/ctf/solve.py new file mode 100644 index 0000000000..733e045ef0 --- /dev/null +++ b/website/public/showcase/ctf/solve.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Solve the DownUnderCTF 2023 masked-squares handout from REA-extracted checks. + +Requires Python 3 and z3-solver. The constants below were read from the original +ELF with SHA-256 dcd3bec4f608e11f3a12ce461100aaf5f621851ee63a1f870d7c90d4e3dd51ae. +Challenge by joseph / DownUnderCTF: +https://github.com/DownUnderCTF/Challenges_2023_Public/tree/b48f83db2984bc7fa2ec7879a32d7efb1be8d333/rev/masked-squares-flag-checker +""" + +from z3 import Int, Or, Solver, Sum, sat + +TARGETS = [1441, 2043, 1259, 2031, 1799, 746, 55, 1450, 1485, 1362, 611, 1314, 358, 1834, 1500, 1355, 2011, 1990, 1939, 1990, 278, 1859, 2111, 1510, 888, 1224] +RUNS = [ + (-5, 1, -2, 1, -1, 3, -3, 2, -1, 1, -1, 1, -1, 1, -1, 2, -3, 2, -2, 2), + (-1, 1, -3, 2, -3, 2, -1, 1, -2, 3, -1, 1, -1, 7, -1, 3, -1, 2), + (-1, 1, -1, 1, -1, 2, -1, 1, -6, 1, -1, 1, -2, 1, -1, 1, -1, 1, -4, 3, -1, 1, -2), + (2, -1, 5, -3, 3, -1, 4, -1, 2, -1, 5, -3, 2, -3), + (-1, 1, -3, 1, -1, 1, -2, 1, -1, 3, -1, 1, -1, 1, -2, 5, -1, 4, -1, 1, -3), + (1, -2, 2, -8, 1, -7, 2, -1, 1, -3, 1, -2, 1, -4), + (-21, 1, -14), + (-2, 3, -4, 4, -5, 1, -2, 1, -1, 1, -2, 1, -2, 1, -1, 4, -1), + (-4, 5, -3, 1, -5, 1, -3, 1, -2, 5, -4, 2), + (-1, 2, -4, 1, -1, 4, -3, 1, -4, 1, -1, 2, -1, 1, -3, 1, -1, 2, -2), + (-2, 1, -8, 1, -1, 1, -3, 1, -1, 1, -7, 1, -8), + (1, -2, 5, -1, 1, -8, 3, -2, 1, -2, 1, -3, 1, -4, 1), + (-3, 1, -9, 1, -2, 1, -17, 1, -1), + (3, -5, 4, -1, 1, -2, 1, -1, 2, -3, 3, -1, 1, -1, 3, -1, 2, -1), + (-2, 2, -3, 1, -1, 1, -1, 1, -1, 1, -7, 1, -1, 6, -1, 3, -1, 1, -1), + (1, -5, 1, -2, 2, -1, 2, -1, 1, -1, 1, -5, 1, -1, 1, -1, 5, -4), + (2, -3, 1, -5, 4, -2, 3, -2, 5, -1, 2, -1, 2, -1, 2), + (-1, 1, -3, 1, -1, 2, -2, 1, -1, 7, -2, 3, -1, 4, -1, 1, -2, 1, -1), + (2, -1, 1, -2, 2, -1, 2, -2, 5, -1, 1, -1, 2, -3, 2, -1, 1, -2, 2, -1, 1), + (1, -3, 3, -1, 1, -2, 1, -1, 6, -1, 1, -1, 2, -2, 1, -1, 1, -1, 3, -1, 2), + (-9, 1, -9, 1, -5, 1, -10), + (1, -1, 2, -2, 1, -1, 9, -1, 1, -1, 1, -7, 2, -2, 1, -2, 1), + (-1, 9, -1, 3, -1, 6, -1, 1, -1, 1, -2, 1, -3, 1, -4), + (-2, 1, -3, 1, -1, 2, -5, 6, -1, 2, -2, 4, -1, 1, -4), + (-2, 1, -4, 1, -1, 1, -2, 1, -3, 1, -3, 1, -3, 3, -6, 1, -2), + (-8, 4, -2, 2, -1, 3, -5, 1, -2, 2, -1, 1, -4), +] + + +def decode_mask(runs): + """Expand signed run lengths into 36 selected/unselected cells.""" + cells = [selected for run in runs for selected in [run > 0] * abs(run)] + if len(cells) != 36: + raise ValueError('A mask must cover all 36 input positions.') + return [i for i, selected in enumerate(cells) if selected] + + +codes = [Int(f'code_{i}') for i in range(36)] +solver = Solver() +solver.set(timeout=60000) +# Analyst assumptions about a conventional flag's format and alphabet. +alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_{}' +for code in codes: + solver.add(Or(*[code == ord(char) for char in alphabet])) +for code, char in zip(codes, 'DUCTF{'): + solver.add(code == ord(char)) +solver.add(codes[-1] == ord('}')) +for runs, target in zip(RUNS, TARGETS): + solver.add(Sum([codes[i] for i in decode_mask(runs)]) == target) + +status = solver.check() +if status != sat: + raise SystemExit(f'No candidate returned: {status}. {solver.reason_unknown()}') +model = solver.model() +values = [model.eval(code).as_long() for code in codes] +if not all(sum(values[i] for i in decode_mask(runs)) == target + for runs, target in zip(RUNS, TARGETS)): + raise SystemExit('Candidate did not pass the extracted checks.') +print(''.join(map(chr, values))) diff --git a/website/public/showcase/dx-ball/index.html b/website/public/showcase/dx-ball/index.html index cec1a9b02e..12cc6854fa 100644 --- a/website/public/showcase/dx-ball/index.html +++ b/website/public/showcase/dx-ball/index.html @@ -25,6 +25,7 @@ Showcases Get started Guides + FAQ GitHub
    diff --git a/website/public/showcase/index.html b/website/public/showcase/index.html index dfe95f3789..23a112d9b7 100644 --- a/website/public/showcase/index.html +++ b/website/public/showcase/index.html @@ -6,7 +6,7 @@ Case studies — REA @@ -25,6 +25,7 @@ Showcases Get started Guides + FAQ GitHub
    @@ -34,11 +35,29 @@

    REA case studies.

    - Game reconstruction and Electron analysis, with examples from DX-Ball, - Notion and TH04. + Game reconstruction, Electron analysis and a CTF challenge, with code, + figures and checked results.

    +
    +

    CTF · Native analysis

    +
    +

    Solve a CTF flag checker

    +

    + Inspect an easy DownUnderCTF challenge with REA. Find its checking + functions, turn masks and sums into equations, then verify the + solved flag with the original program. +

    +
    + Binary + Checking code + Equations + Correct! +
    + Read the CTF case study +
    +

    Native · Reconstruction

    diff --git a/website/public/showcase/notion/index.html b/website/public/showcase/notion/index.html index 4d98db5c38..d5dacc1782 100644 --- a/website/public/showcase/notion/index.html +++ b/website/public/showcase/notion/index.html @@ -25,6 +25,7 @@ Showcases Get started Guides + FAQ GitHub
    diff --git a/website/public/showcase/th04/index.html b/website/public/showcase/th04/index.html index 52bfce2336..1fd59e7079 100644 --- a/website/public/showcase/th04/index.html +++ b/website/public/showcase/th04/index.html @@ -25,6 +25,7 @@ Showcases Get started Guides + FAQ GitHub