fix(native): preserve signature uncertainty and validate ticket framing

This commit is contained in:
morluto committed 2026-10-08 23:16:28 +08:00
commit c1535d88ab
132 files changed
+4003 -1593

No files matched your search

+1 -1
View File
@@ -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
+17
View File
@@ -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
-75
View File
@@ -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}"
+2 -36
View File
@@ -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
+8
View File
@@ -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
+2 -2
View File
@@ -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.
+19 -3
View File
@@ -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
+6 -2
View File
@@ -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.
<a href="https://trendshift.io/repositories/82054?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-82054" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/82054" alt="morluto%2Frea | Trendshift" width="250" height="55"/></a>
**[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).
+4
View File
@@ -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 ويبلّغ عن الأخطاء ويختبر الإصدارات ويساهم في إصلاحها.
<a href="https://trendshift.io/repositories/82054?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-82054" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/82054" alt="morluto%2Frea | Trendshift" width="250" height="55"/></a>
**[الموقع (بالإنجليزية)](https://morluto.github.io/rea/) · [أدلة الاستخدام](https://morluto.github.io/rea/guides/) · [دراسة حالة DX-Ball](https://morluto.github.io/rea/showcase/dx-ball/)**
+4
View File
@@ -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 を使い、問題を報告し、ビルドをテストし、修正を届けてくださる皆さんに感謝します。
<a href="https://trendshift.io/repositories/82054?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-82054" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/82054" alt="morluto%2Frea | Trendshift" width="250" height="55"/></a>
**[ウェブサイト(英語)](https://morluto.github.io/rea/) · [ガイド](https://morluto.github.io/rea/guides/) · [DX-Ball の事例](https://morluto.github.io/rea/showcase/dx-ball/)**
+4
View File
@@ -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를 사용하고, 버그를 제보하고, 빌드를 테스트하고, 수정에 기여해 주신 모든 분께 감사드립니다.
<a href="https://trendshift.io/repositories/82054?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-82054" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/82054" alt="morluto%2Frea | Trendshift" width="250" height="55"/></a>
**[웹사이트(영문)](https://morluto.github.io/rea/) · [가이드](https://morluto.github.io/rea/guides/) · [DX-Ball 사례](https://morluto.github.io/rea/showcase/dx-ball/)**
+4
View File
@@ -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、反馈问题、测试构建和贡献修复的朋友。
<a href="https://trendshift.io/repositories/82054?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-82054" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/82054" alt="morluto%2Frea | Trendshift" width="250" height="55"/></a>
**[网站(英文)](https://morluto.github.io/rea/) · [使用指南](https://morluto.github.io/rea/guides/) · [DX-Ball 案例](https://morluto.github.io/rea/showcase/dx-ball/)**
-2
View File
@@ -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.
+31 -1
View File
@@ -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<UInt32>.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 {
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+10
View File
@@ -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
+6
View File
@@ -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:
-722
View File
@@ -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"
}
}
}
+8 -5
View File
@@ -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
@@ -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
}
}
@@ -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"
}
}
]
}
+3 -3
View File
@@ -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;
+3 -1
View File
@@ -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",
-5
View File
@@ -34,11 +34,6 @@
"type": "json",
"path": "server.json",
"jsonpath": "$.packages[0].version"
},
{
"type": "json",
"path": "docs/product-catalog.json",
"jsonpath": "$.package.version"
}
]
}
+1 -1
View File
@@ -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 = {
+1 -1
View File
@@ -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];
+4 -1
View File
@@ -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",
+62 -19
View File
@@ -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;
}
+13 -3
View File
@@ -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;
};
+3 -3
View File
@@ -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),
);
+2 -7
View File
@@ -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`,
+1 -1
View File
@@ -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}`,
+3 -1
View File
@@ -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,
+21
View File
@@ -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"),
+10 -8
View File
@@ -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++;
}
+4 -2
View File
@@ -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 =
@@ -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
@@ -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);
},
);
+28 -5
View File
@@ -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 ||
+14 -7
View File
@@ -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<typeof registrationSchema>,
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 (
+2 -1
View File
@@ -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,
});
+1
View File
@@ -177,6 +177,7 @@ describe("doctor installation identity", () => {
version: "10",
toolCount: null,
catalogDigest: null,
canonical: false,
}),
}),
);
+7 -2
View File
@@ -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<string | undefined>;
}
/** 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),
+19 -6
View File
@@ -1,4 +1,6 @@
import {
describeEvidenceBundleFailure,
describeValidationFailure,
parseEvidenceBundle,
serializeEvidenceBundle,
type EvidenceBundle,
@@ -20,20 +22,31 @@ export const readEvidenceBundle = async (
): Promise<Result<EvidenceBundle, EvidenceReadFailure>> => {
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. */
+44 -9
View File
@@ -10,10 +10,14 @@ import { err, ok, type Result } from "../domain/result.js";
export const readJsonFile = async (
path: string,
): Promise<Result<unknown, EvidenceFileError>> => {
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,
+2 -6
View File
@@ -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 = (
+2 -1
View File
@@ -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,
@@ -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),
}),
);
}
+15 -5
View File
@@ -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");
};
@@ -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"),
);
});
});
@@ -48,10 +48,15 @@ export const analyzeAppleAssetCatalogs = async (input: {
sha256: string;
records: ReturnType<typeof parseAppleAssetCatalogRecords>;
}[] = [];
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
? [
+11 -1
View File
@@ -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"],
}),
],
}),
);
});
});
+27 -10
View File
@@ -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 },
]);
@@ -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 = (
@@ -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);
+18 -4
View File
@@ -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", () => {
+3 -5
View File
@@ -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(
+2 -1
View File
@@ -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.",
},
@@ -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),
+9 -3
View File
@@ -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")
+5 -1
View File
@@ -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;
};
+9 -1
View File
@@ -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
+39 -1
View File
@@ -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);
});
+6 -2
View File
@@ -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"]),
+28
View File
@@ -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);
+19 -3
View File
@@ -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;
}
}
@@ -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. */
+31
View File
@@ -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);
}
},
);
+63 -43
View File
@@ -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<string, unknown> =>
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<JsonValue>(
Object.entries(value).map(([key, child]) => [
key,
preserveJsonValue(child),
]),
);
return value;
};
const validatedJsonValue = z.custom<JsonValue>(
(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<Record<string, JsonValue>>(
isRecord,
"Expected JSON object",
);
const objectProjection = () => ({ type: "object", additionalProperties: {} });
objectValidation._zod.toJSONSchema = objectProjection;
export type JsonValue = z.infer<typeof jsonValueSchema>;
/** JSON object boundary for caller-visible parameter maps. */
export const jsonObjectSchema = jsonValueSchema.pipe(objectValidation);
jsonObjectSchema._zod.toJSONSchema = objectProjection;
+19
View File
@@ -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 = <Schema extends z.ZodType>(
+5 -5
View File
@@ -100,6 +100,7 @@ export const signatureVerification = (
capture: NativeCommandCapture,
path: string,
unsigned: boolean,
progressCompleteness: "complete" | "ambiguous" = "complete",
): NonNullable<InspectSignature["verification"]> => {
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: [
+41 -4
View File
@@ -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);
});
+36 -17
View File
@@ -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<string[]> => {
): Promise<NestedCodePathInspection> => {
signal?.throwIfAborted();
const limitations: string[] = [];
const directories = new Map<string, Promise<boolean>>();
const directories = new Map<string, Promise<ReadonlySet<string>>>();
let progressAmbiguous = false;
const records = new Map<string, "prepared" | "validated">();
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 };
};
+28 -7
View File
@@ -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(
+13
View File
@@ -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",
);
});
+6 -4
View File
@@ -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";
+94 -84
View File
@@ -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", () => {
@@ -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();
},
);
});
+140 -5
View File
@@ -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 = [
+272 -52
View File
@@ -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<ProcessCleanupResult> => {
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<number, string | null>;
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<string, number>();
@@ -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<number, string | null>;
}
/** 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<TokenOwnedProcessScan> => {
let candidates = processes;
let candidateIdentities: ReadonlyMap<number, ProcessIdentityObservation> =
@@ -678,61 +779,98 @@ const scanTokenOwnedProcesses = async (
}
candidates = stableCandidates;
}
const owned: ProcessTableEntry[] = [];
const ownedIdentities = new Map<number, string | null>();
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<number, ProcessRunTokenObservation> | undefined;
if (host.runTokens !== undefined)
bulkTokens = await host.runTokens(candidates);
const owned: ProcessTableEntry[] = [];
const ownedIdentities = new Map<number, string | null>();
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<number>();
const relatedGroupIds = new Set<number>();
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<number> | 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<readonly UnreadableTokenCandidate[]> => {
let remaining = pending;
for (
let attempt = 0;
attempt < TOKEN_READ_RETRY_ATTEMPTS && remaining.length > 0;
attempt += 1
) {
await new Promise<void>((resolve) =>
setTimeout(resolve, TOKEN_READ_RETRY_DELAY_MS),
);
let live: ReadonlySet<number> | undefined;
try {
live = new Set(
liveProcesses(await host.listProcesses()).map(({ pid }) => pid),
);
} catch (cause: unknown) {
void cause;
live = undefined;
}
let observations:
| ReadonlyMap<number, ProcessRunTokenObservation>
| 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<ProcessRunTokenObservation> => {
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 (
+7 -4
View File
@@ -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<
+38 -3
View File
@@ -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) {
@@ -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 },
+1 -1
View File
@@ -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",
+42 -2
View File
@@ -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",
+8 -3
View File
@@ -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),
@@ -164,7 +164,7 @@ const createBuildCacheFixture = async (): Promise<string> => {
"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(
@@ -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:",
),
},
});
});
});
@@ -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",
),
});
});
@@ -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<string, unknown>) =>
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),
});
},
);
@@ -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();
@@ -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(
Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff. Show more