fix(native): preserve signature uncertainty and validate ticket framing
This commit is contained in:
commit
c1535d88ab
132 files changed
+4003
-1593
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -10,12 +10,16 @@
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/product-catalog.json)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](https://nodejs.org/)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
[](LICENSE)
|
||||
[](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&utm_medium=badge&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).
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/GkcryMnJDM)
|
||||
|
||||
🎉 **وصلنا إلى 20,000 نجمة على GitHub، شكرًا لكم!**
|
||||
|
||||
شكرًا لكل من يستخدم REA ويبلّغ عن الأخطاء ويختبر الإصدارات ويساهم في إصلاحها.
|
||||
|
||||
<a href="https://trendshift.io/repositories/82054?utm_source=repository-badge&utm_medium=badge&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/)**
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/GkcryMnJDM)
|
||||
|
||||
🎉 **GitHub スターが 20,000 を突破しました。ありがとうございます!**
|
||||
|
||||
REA を使い、問題を報告し、ビルドをテストし、修正を届けてくださる皆さんに感謝します。
|
||||
|
||||
<a href="https://trendshift.io/repositories/82054?utm_source=repository-badge&utm_medium=badge&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/)**
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/GkcryMnJDM)
|
||||
|
||||
🎉 **GitHub 스타 20,000개, 감사합니다!**
|
||||
|
||||
REA를 사용하고, 버그를 제보하고, 빌드를 테스트하고, 수정에 기여해 주신 모든 분께 감사드립니다.
|
||||
|
||||
<a href="https://trendshift.io/repositories/82054?utm_source=repository-badge&utm_medium=badge&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/)**
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/GkcryMnJDM)
|
||||
|
||||
🎉 **GitHub Star 突破 20,000,感谢大家!**
|
||||
|
||||
感谢每一位使用 REA、反馈问题、测试构建和贡献修复的朋友。
|
||||
|
||||
<a href="https://trendshift.io/repositories/82054?utm_source=repository-badge&utm_medium=badge&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/)**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -34,11 +34,6 @@
|
||||
"type": "json",
|
||||
"path": "server.json",
|
||||
"jsonpath": "$.packages[0].version"
|
||||
},
|
||||
{
|
||||
"type": "json",
|
||||
"path": "docs/product-catalog.json",
|
||||
"jsonpath": "$.package.version"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
@@ -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`,
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
-2
@@ -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
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -177,6 +177,7 @@ describe("doctor installation identity", () => {
|
||||
version: "10",
|
||||
toolCount: null,
|
||||
catalogDigest: null,
|
||||
canonical: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
? [
|
||||
|
||||
@@ -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"],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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>(
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
Reference in new issue
Block a user