Compare commits
79
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f947c859c | ||
|
|
a446f2a413 | ||
|
|
5be3eeb1e6 | ||
|
|
a1ed4232de | ||
|
|
0cbef126a5 | ||
|
|
4adca806f9 | ||
|
|
c701376777 | ||
|
|
2af12a18a3 | ||
|
|
5e1afe454f | ||
|
|
a9e00b83f3 | ||
|
|
22e6110acb | ||
|
|
5f93b4b125 | ||
|
|
8289ede5db | ||
|
|
e4d61d401d | ||
|
|
46d195e8a4 | ||
|
|
9833c94656 | ||
|
|
6e6ff7bd2e | ||
|
|
4e98543e90 | ||
|
|
9455e7c8bc | ||
|
|
e6134056cf | ||
|
|
b49beb42d7 | ||
|
|
a4d77f0ff0 | ||
|
|
82a1a525d7 | ||
|
|
e8b92a40c0 | ||
|
|
d52f66d62a | ||
|
|
e2f03786f4 | ||
|
|
5a55d5b1c6 | ||
|
|
ef30343154 | ||
|
|
38bbb656da | ||
|
|
89252f1f35 | ||
|
|
5547c1e697 | ||
|
|
cf96ceb211 | ||
|
|
a079af297b | ||
|
|
79e5a3923d | ||
|
|
25fb6d32a3 | ||
|
|
40e18b2e37 | ||
|
|
03bbecd941 | ||
|
|
2645851adf | ||
|
|
d39543145e | ||
|
|
bfbfc7330a | ||
|
|
3dcb732da3 | ||
|
|
6a7650e93c | ||
|
|
b23f8288b4 | ||
|
|
170600541b | ||
|
|
68b9fa489b | ||
|
|
cb8d0f37f4 | ||
|
|
6b99236c36 | ||
|
|
fb999f84b8 | ||
|
|
8a31508c89 | ||
|
|
f5009ed55f | ||
|
|
fe3cac8aa8 | ||
|
|
60825fb447 | ||
|
|
5dfd9905ed | ||
|
|
17f2bc7b0d | ||
|
|
5ccb9241be | ||
|
|
f4f82179d9 | ||
|
|
0f6dd6966c | ||
|
|
92fcab2c68 | ||
|
|
dbc7ebc2b6 | ||
|
|
d4ba8f661b | ||
|
|
24c8e13909 | ||
|
|
e1597a6f3d | ||
|
|
d5921db039 | ||
|
|
663f4c4dc5 | ||
|
|
d081ffbdce | ||
|
|
cd622e0e95 | ||
|
|
f2f83c7499 | ||
|
|
d180d8667d | ||
|
|
3d6a31ed41 | ||
|
|
09344aa33d | ||
|
|
8e53f794f1 | ||
|
|
858aa9fd7d | ||
|
|
ed40d2e001 | ||
|
|
3b4969e3dc | ||
|
|
1d943c5eec | ||
|
|
305474df6c | ||
|
|
202661f83b | ||
|
|
22beae9768 | ||
|
|
ade11fa02d |
@@ -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
|
||||
|
||||
@@ -59,6 +59,10 @@ relevant real-provider or platform workflow, or explain why it was unavailable.
|
||||
limits, security/privacy/containment implications, and meaningful proof gaps. -->
|
||||
|
||||
- Breaking changes or migration steps:
|
||||
<!-- For ! / BREAKING CHANGE: show a previously valid call/configuration,
|
||||
its new behavior and migration, and why compatibility/deprecation cannot
|
||||
preserve it. Compatible fixes/additions should use patch/minor semantics.
|
||||
See docs/releasing.md. -->
|
||||
- Real Hopper/Ghidra, browser, or OS coverage:
|
||||
- Package or release metadata impact:
|
||||
- Security, privacy, process, or containment review:
|
||||
|
||||
@@ -102,8 +102,8 @@ jobs:
|
||||
with:
|
||||
name: rea-dist
|
||||
path: dist
|
||||
- name: Generate source catalog from downloaded build
|
||||
run: node scripts/generate-mcp-tool-catalog.mjs
|
||||
- name: Generate projections from downloaded build
|
||||
run: npm run build:projections
|
||||
- run: npm run test:ci:shard:run -- --shard=${{ matrix.shard }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
@@ -317,8 +317,9 @@ jobs:
|
||||
- run: npm run verify:keyed-archive
|
||||
- run: npm run verify:asset-catalog
|
||||
- run: npm run verify:macos-bundle
|
||||
- name: Native compiled NIB hierarchy regressions
|
||||
run: npm run test:focused -- tests/boundary/filesystem/nibNativeHierarchy.test.ts tests/boundary/filesystem/interfaceBuilderBundle.test.ts
|
||||
- run: npm run verify:dyld-shared-cache
|
||||
- name: Native installer and compiled NIB hierarchy regressions
|
||||
run: npm run test:focused -- tests/boundary/filesystem/installScript.test.ts tests/boundary/filesystem/nibNativeHierarchy.test.ts tests/boundary/filesystem/interfaceBuilderBundle.test.ts
|
||||
- name: Real binary metadata and Interface Builder integration
|
||||
run: npm run verify:apple-dispatch && npm run verify:interface-builder
|
||||
- name: Native plist CLI selection regressions
|
||||
@@ -327,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
|
||||
@@ -441,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
|
||||
@@ -481,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}"
|
||||
@@ -84,6 +84,32 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check out release controller and Git history
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js for checkpoint validation
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install checkpoint validation dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Validate checkpoint version and ancestry before release creation
|
||||
env:
|
||||
RELEASE_PHASE: ${{ inputs.phase }}
|
||||
RELEASE_BRANCH: ${{ inputs.release_branch }}
|
||||
SOURCE_SHA: ${{ steps.branch.outputs.sha }}
|
||||
run: >-
|
||||
node scripts/verify-release-checkpoint.mjs
|
||||
--phase "$RELEASE_PHASE" --release-branch "$RELEASE_BRANCH"
|
||||
--source-sha "$SOURCE_SHA"
|
||||
|
||||
- uses: googleapis/release-please-action@v4
|
||||
id: release
|
||||
with:
|
||||
@@ -116,6 +142,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ fromJSON(steps.release.outputs.pr).headBranchName }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js for generated documentation
|
||||
@@ -129,47 +156,23 @@ jobs:
|
||||
if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Validate prepared release version and migration notes
|
||||
if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'
|
||||
env:
|
||||
RELEASE_BRANCH: ${{ inputs.release_branch }}
|
||||
SOURCE_SHA: ${{ steps.branch.outputs.sha }}
|
||||
run: >-
|
||||
node scripts/verify-release-checkpoint.mjs
|
||||
--phase prepare --stage candidate --release-branch "$RELEASE_BRANCH"
|
||||
--source-sha "$SOURCE_SHA"
|
||||
|
||||
- name: Regenerate release documentation
|
||||
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
|
||||
|
||||
@@ -21,10 +21,10 @@ REA is a layered ESM TypeScript application. Dependencies flow inward from pure
|
||||
|
||||
- `src/domain/` owns pure provider-neutral semantics; `src/contracts/` owns caller-visible schemas and the canonical tool inventory.
|
||||
- `src/hopper/`, `src/ghidra/`, `src/ida/`, `src/browser/`, `src/inspector/`, `src/native/`, `src/artifacts/`, and `src/dotnet/` own provider-specific boundaries. Keep provider protocols out of domain and application code.
|
||||
- `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.
|
||||
- `src/application/` composes shared CLI/MCP workflows, including Evidence provenance, unknowns, and eligible snapshot bindings; `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. Reuse its supervision and identity primitives before adding provider-local lifecycle code; a PID and executable pathname alone do not establish ownership after exit or reuse. `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.
|
||||
|
||||
@@ -54,9 +54,11 @@ Use ESM TypeScript, two-space indentation, and the committed Oxfmt configuration
|
||||
|
||||
Treat a boundary as a contract between the producer's actual representation and the consumer's required meaning. When implementing or auditing a boundary, trace the value through parsing, normalization, authorization, serialization, and the CLI/MCP result. Establish affected callers from their code paths; similar tool names or workflows do not prove that they share a schema or failure mode.
|
||||
|
||||
Keep portable evidence and scenario validation distinct from host-native execution checks. Absolute filesystem paths, file URLs, and HTTP paths have different semantics; do not substitute one platform's syntax for the domain concept. Interpret provider metadata according to its documented or observed producer behavior. When a transformation loses information, preserve the reported value and an explicit unknown rather than guessing a canonical identity.
|
||||
When changing boundary behavior, inspect adjacent input representations, failure paths, and affected callers, and correct the underlying assumption across those cases.
|
||||
|
||||
Preserve meaningful failure reasons through application and adapter layers. Malformed input is distinct from an unsupported target, unavailable provider, or host operating-system permission denial. Diagnostics should identify the failed constraint and the target or lifecycle request it applies to. Recovery advice must address that reason and point to an available workflow; generic catches must not erase actionable validation details.
|
||||
Keep portable evidence and scenario validation distinct from host-native execution checks. Absolute filesystem paths, file URLs, and HTTP paths have different semantics; do not substitute one platform's syntax for the domain concept. Interpret provider metadata according to its documented or observed producer behavior. Keep values used for identity, provenance, matching, and path resolution separate from display formatting. When normalization loses information, preserve the source value and an explicit unknown; display placeholders must not feed back into lookup or selection.
|
||||
|
||||
Preserve meaningful failure reasons through application and adapter layers. Malformed input is distinct from an unsupported target, unavailable provider, or host operating-system permission denial. Diagnostics should identify the failed constraint and the target or lifecycle request it applies to. Recovery advice must address that reason and point to an available workflow; generic catches must not erase actionable validation details. Preserve collected observations on execution failure independently of whether cleanup succeeds.
|
||||
|
||||
Leave meaningful target, action, capture, and output choices to the agent. A selected operation already expresses intent; do not require approval booleans or repeated permission declarations. Trace each setting to its consumer: remove ignored options and single-value confirmations, derive built-in lifecycle behavior, and supply defaults for omitted optional metadata. Report actual effects and limitations where they help interpret results rather than asking callers to restate them.
|
||||
|
||||
@@ -80,7 +82,7 @@ Let agents compose experiments with ordinary commands, scripts, and local fixtur
|
||||
- Use **observe/capture** only when runtime activity is required, and declare authority and lifecycle effects in the contract.
|
||||
- Extend an existing tool when intent and result contract are unchanged. Add a tool for a distinct analyst outcome or materially different authority.
|
||||
- Keep caller-facing names and results provider-neutral. Put engine-specific behavior in provider adapters and report each provider's exact coverage.
|
||||
- Keep results complete by default. Add a limit only when it follows from a real format, protocol, authority, or resource-safety constraint; explain truncation and unsupported facets. Keep observed, derived, inferred, and unknown results distinct.
|
||||
- Keep results complete by default. Add a limit only when it follows from a real format, protocol, authority, or resource-safety constraint; explain truncation and unsupported facets. Account for representation expansion and products of independently bounded dimensions before allocating or retaining output. Derive facet completeness from what was examined and exhausted, not an empty failure list. Keep observed, derived, inferred, and unknown results distinct.
|
||||
- Include artifact identity, source locations, Evidence references, actionable errors, and relevant limitations when they affect conclusions. Return the evidence needed for the next analysis inline rather than requiring a resource or identifier lookup.
|
||||
- Implement shared application workflows behind CLI and MCP adapters. Update canonical contracts and generated catalog together.
|
||||
|
||||
|
||||
+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,7 +10,7 @@
|
||||
|
||||
[](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)
|
||||
@@ -296,7 +296,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).
|
||||
@@ -315,12 +315,12 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and checks,
|
||||
|
||||
[Website](https://morluto.github.io/rea/) · [npm](https://www.npmjs.com/package/rea-agents) · [skills.sh](https://skills.sh/morluto/rea/reverse-engineer-anything) · [Issues](https://github.com/morluto/rea/issues) · [Security](SECURITY.md)
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
## Star history
|
||||
|
||||
🎉 **20,000 GitHub stars — thank you!**
|
||||
|
||||
Thanks to everyone using REA, reporting bugs, testing builds, and contributing fixes.
|
||||
|
||||
<a href="https://www.star-history.com/?repos=morluto%2Frea&type=date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=morluto/rea&type=date&theme=dark&legend=top-left" />
|
||||
@@ -332,3 +332,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and checks,
|
||||
## Disclaimer
|
||||
|
||||
REA provides tools for lawful reverse-engineering research, analysis, and reconstruction. You are responsible for obtaining any required authorization and complying with applicable laws. The project does not endorse illegal or unauthorized use.
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
+28
-14
@@ -213,20 +213,20 @@ rea setup
|
||||
|
||||
## منصة أدوات التحقيق
|
||||
|
||||
| عائلة الأدوات | العدد | الاستخدام |
|
||||
| ---------------------- | ----: | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| الفحص الأصلي | 41 | الدوال والشيفرة شبه المصدرية والتعليمات والسلاسل والرموز والاستدعاءات والمراجع والتعليقات وقراءة البايتات ومواضع الملفات |
|
||||
| سير عمل التحقيق | 14 | نظرات عامة على التطبيقات وملفات الدوال وواجهات API الأصلية والتوزيع وفك الترجمة الدفعي وتتبع الميزات ومسارات الاستدعاء ومخططاته واكتشاف Swift وObjective-C |
|
||||
| أدوات الملفات الثنائية | 10 | بيانات Mach-O والتوقيعات وplist والمعماريات وأسماء Swift ومراقبة الاستدعاءات عبر LLDB على macOS؛ تخطيط ELF وأدلة الحماية الثابتة وسجلات الأعطال على Linux |
|
||||
| مخطط المخرجات | 7 | جرد الدلائل والحزم وملفات Interface Builder المترجمة وكتالوجات موارد Apple وتحليل تحميل Mach-O dylib والاستخراج واستدلال واجهات EVM دون اتصال |
|
||||
| PE/CLI المُدار | 7 | هوية تجميعات .NET وبياناتها الوصفية وتعليمات CIL والاعتماديات الأصلية واستيراد إعادة البناء ومقارنة الإصدارات |
|
||||
| البرامج الثابتة | 2 | فحص مناطق البرامج الثابتة على Linux واستخراجها الصريح |
|
||||
| Android APK | 5 | بيانات الحزمة وmanifest والبحث عن الأصناف وقوائم الأعضاء وفك ترجمة الأساليب والمراجع الثابتة |
|
||||
| مراقبة المتصفح | 12 | بنية الصفحات وبيانات الشبكة والبرامج النصية وخرائط المصدر واكتشاف WebMCP والصور ومقارنة الالتقاط |
|
||||
| تحليل Electron | 5 | مراقبة العارضين وخريطة التطبيق الثابتة والمواءمة بين نتائج الثبات والتشغيل |
|
||||
| وقت تشغيل JavaScript | 2 | اكتشاف أهداف Node/Electron Inspector ومواضع البرامج وأحداث سياقات التنفيذ |
|
||||
| سير عمل التطبيقات | 13 | تصدير النصوص البرمجية الملتقطة، تتبع الميزات عبر الطبقات ومقارنة الإصدارات ومواءمة المصدر التاريخي ومقارنة أشكال القيم المعادة والتحقق من إعادة الإنشاء |
|
||||
| مساحة العمل والملاحظة | 21 | الجلسات وحزم الأدلة وسياق التنقل ومقارنة العمليات والمخرجات والدوال وتسجيل الأسئلة غير المحسومة |
|
||||
| عائلة الأدوات | العدد | الاستخدام |
|
||||
| ---------------------- | ----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| الفحص الأصلي | 41 | الدوال والشيفرة شبه المصدرية والتعليمات والسلاسل والرموز والاستدعاءات والمراجع والتعليقات وقراءة البايتات ومواضع الملفات |
|
||||
| سير عمل التحقيق | 14 | نظرات عامة على التطبيقات وملفات الدوال وواجهات API الأصلية والتوزيع وفك الترجمة الدفعي وتتبع الميزات ومسارات الاستدعاء ومخططاته واكتشاف Swift وObjective-C |
|
||||
| أدوات الملفات الثنائية | 10 | بيانات Mach-O والتوقيعات وplist والمعماريات وأسماء Swift ومراقبة الاستدعاءات عبر LLDB على macOS؛ تخطيط ELF وأدلة الحماية الثابتة وسجلات الأعطال على Linux |
|
||||
| مخطط المخرجات | 7 | جرد الدلائل والحزم وملفات Interface Builder المترجمة وكتالوجات موارد Apple وتحليل تحميل Mach-O dylib والاستخراج واستدلال واجهات EVM دون اتصال |
|
||||
| PE/CLI المُدار | 7 | هوية تجميعات .NET وبياناتها الوصفية وتعليمات CIL والاعتماديات الأصلية واستيراد إعادة البناء ومقارنة الإصدارات |
|
||||
| البرامج الثابتة | 2 | فحص مناطق البرامج الثابتة على Linux واستخراجها الصريح |
|
||||
| Android APK | 5 | بيانات الحزمة وmanifest والبحث عن الأصناف وقوائم الأعضاء وفك ترجمة الأساليب والمراجع الثابتة |
|
||||
| مراقبة المتصفح | 12 | بنية الصفحات وبيانات الشبكة والبرامج النصية وخرائط المصدر واكتشاف WebMCP والصور ومقارنة الالتقاط |
|
||||
| تحليل Electron | 5 | مراقبة العارضين وخريطة التطبيق الثابتة والمواءمة بين نتائج الثبات والتشغيل |
|
||||
| وقت تشغيل JavaScript | 2 | اكتشاف أهداف Node/Electron Inspector ومواضع البرامج وأحداث سياقات التنفيذ |
|
||||
| سير عمل التطبيقات | 14 | تصدير النصوص البرمجية الملتقطة، فحص ذاكرة dyld المشتركة، تتبع الميزات عبر الطبقات ومقارنة الإصدارات ومواءمة المصدر التاريخي ومقارنة أشكال القيم المعادة والتحقق من إعادة الإنشاء |
|
||||
| مساحة العمل والملاحظة | 21 | الجلسات وحزم الأدلة وسياق التنقل ومقارنة العمليات والمخرجات والدوال وتسجيل الأسئلة غير المحسومة |
|
||||
|
||||
## خطة العمل
|
||||
|
||||
@@ -336,6 +336,20 @@ npx -y rea-agents@latest compare /absolute/path/to/left-evidence.json /absolute/
|
||||
- [Hopper Disassembler](https://www.hopperapp.com/)
|
||||
- [سياسة الأمان](SECURITY.md)
|
||||
|
||||
## تاريخ النجوم
|
||||
|
||||
🎉 **وصلنا إلى 20,000 نجمة على GitHub، شكرًا لكم!**
|
||||
|
||||
شكرًا لكل من يستخدم REA ويبلّغ عن الأخطاء ويختبر الإصدارات ويساهم في إصلاحها.
|
||||
|
||||
<a href="https://www.star-history.com/?repos=morluto%2Frea&type=date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=morluto/rea&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=morluto/rea&type=date" />
|
||||
<img alt="تاريخ نجوم REA على GitHub" src="https://api.star-history.com/chart?repos=morluto/rea&type=date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## الترخيص
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
+28
-14
@@ -236,20 +236,20 @@ TypeScript と SQLite を使って私のプロジェクト向けに実装して
|
||||
|
||||
## 調査ツールカタログ
|
||||
|
||||
| ツール分類 | 数 | 用途 |
|
||||
| -------------------- | --: | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ネイティブ検査 | 41 | 関数、疑似コード、アセンブリ、文字列、シンボル、呼び出し、参照、注釈、バイト読み取り、ファイルオフセット |
|
||||
| 調査ワークフロー | 14 | アプリ概要、関数ドシエ、ネイティブ API とディスパッチ、一括逆コンパイル、機能トレース、コールパス、コールグラフ、Swift と Objective-C の検出 |
|
||||
| ネイティブバイナリ | 10 | macOS の Mach-O メタデータ、署名、plist、アーキテクチャ、Swift シンボル、LLDB による呼び出し観測;Linux の ELF レイアウト、静的な保護機構の証拠と記録済みクラッシュ |
|
||||
| 成果物グラフ | 7 | ディレクトリとパッケージの目録、コンパイル済み Interface Builder ファイル、Apple アセットカタログ、Mach-O dylib のロード解決、抽出、オフライン EVM インターフェース推定 |
|
||||
| マネージド PE/CLI | 7 | .NET の識別情報、メタデータ、CIL 命令、ネイティブ依存関係、再構築結果のインポート、ビルド比較 |
|
||||
| ファームウェア | 2 | Linux ファームウェア領域の検査と明示的な展開 |
|
||||
| Android APK | 5 | パッケージと manifest の宣言、クラス検索、メンバー一覧、メソッドの逆コンパイル、静的な被参照 |
|
||||
| ブラウザー観察 | 12 | ページ構成、ネットワークメタデータ、スクリプト、ソースマップ、WebMCP の検出、スクリーンショット、キャプチャ比較 |
|
||||
| Electron 解析 | 5 | レンダラーの観察、静的なアプリのマッピング、静的結果と実行時結果の照合 |
|
||||
| JavaScript 実行時 | 2 | Node/Electron Inspector の対象検出、スクリプトの位置、実行コンテキストのイベント |
|
||||
| アプリワークフロー | 13 | 取得した Web スクリプトのエクスポート、Android/Apple の目録の投影、層をまたぐ機能トレース、ビルド比較、過去のソースとの対応付け、静的な戻り値形状の比較、再構築の検証 |
|
||||
| ワークスペースと観察 | 21 | セッション、Evidence バンドル、ナビゲーションの文脈、プロセス・成果物・関数の比較、未解決事項の記録 |
|
||||
| ツール分類 | 数 | 用途 |
|
||||
| -------------------- | --: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| ネイティブ検査 | 41 | 関数、疑似コード、アセンブリ、文字列、シンボル、呼び出し、参照、注釈、バイト読み取り、ファイルオフセット |
|
||||
| 調査ワークフロー | 14 | アプリ概要、関数ドシエ、ネイティブ API とディスパッチ、一括逆コンパイル、機能トレース、コールパス、コールグラフ、Swift と Objective-C の検出 |
|
||||
| ネイティブバイナリ | 10 | macOS の Mach-O メタデータ、署名、plist、アーキテクチャ、Swift シンボル、LLDB による呼び出し観測;Linux の ELF レイアウト、静的な保護機構の証拠と記録済みクラッシュ |
|
||||
| 成果物グラフ | 7 | ディレクトリとパッケージの目録、コンパイル済み Interface Builder ファイル、Apple アセットカタログ、Mach-O dylib のロード解決、抽出、オフライン EVM インターフェース推定 |
|
||||
| マネージド PE/CLI | 7 | .NET の識別情報、メタデータ、CIL 命令、ネイティブ依存関係、再構築結果のインポート、ビルド比較 |
|
||||
| ファームウェア | 2 | Linux ファームウェア領域の検査と明示的な展開 |
|
||||
| Android APK | 5 | パッケージと manifest の宣言、クラス検索、メンバー一覧、メソッドの逆コンパイル、静的な被参照 |
|
||||
| ブラウザー観察 | 12 | ページ構成、ネットワークメタデータ、スクリプト、ソースマップ、WebMCP の検出、スクリーンショット、キャプチャ比較 |
|
||||
| Electron 解析 | 5 | レンダラーの観察、静的なアプリのマッピング、静的結果と実行時結果の照合 |
|
||||
| JavaScript 実行時 | 2 | Node/Electron Inspector の対象検出、スクリプトの位置、実行コンテキストのイベント |
|
||||
| アプリワークフロー | 14 | 取得した Web スクリプトのエクスポート、Android/Apple の目録の投影、dyld 共有キャッシュの検査、層をまたぐ機能トレース、ビルド比較、過去のソースとの対応付け、静的な戻り値形状の比較、再構築の検証 |
|
||||
| ワークスペースと観察 | 21 | セッション、Evidence バンドル、ナビゲーションの文脈、プロセス・成果物・関数の比較、未解決事項の記録 |
|
||||
|
||||
## ロードマップ
|
||||
|
||||
@@ -366,6 +366,20 @@ REA にホスト型の解析サービスはありません。現在のプロバ
|
||||
|
||||
開発環境、アーキテクチャ、テスト、リリース手順は [CONTRIBUTING.md](CONTRIBUTING.md) を参照してください。
|
||||
|
||||
## スター履歴
|
||||
|
||||
🎉 **GitHub スターが 20,000 を突破しました。ありがとうございます!**
|
||||
|
||||
REA を使い、問題を報告し、ビルドをテストし、修正を届けてくださる皆さんに感謝します。
|
||||
|
||||
<a href="https://www.star-history.com/?repos=morluto%2Frea&type=date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=morluto/rea&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=morluto/rea&type=date" />
|
||||
<img alt="REA の GitHub スター履歴" src="https://api.star-history.com/chart?repos=morluto/rea&type=date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## ライセンス
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
+15
-1
@@ -215,7 +215,7 @@ REA는 1~5단계의 바이너리 분석을 처리합니다. 6단계는 에이전
|
||||
| 브라우저 관찰 | 12 | 페이지 구조, 네트워크 메타데이터, 스크립트, 소스 맵, WebMCP 탐색, 스크린샷, 캡처 비교 |
|
||||
| Electron 분석 | 5 | 렌더러 관찰, 정적 앱 매핑, 정적·런타임 결과 연결 |
|
||||
| JavaScript 런타임 | 2 | Node/Electron Inspector 대상 탐색, 스크립트 위치, 실행 컨텍스트 이벤트 |
|
||||
| 앱 워크플로 | 13 | 캡처한 웹 스크립트 내보내기, 계층 간 기능 추적, 빌드 비교, 히스토리 소스 매핑, 정적 반환 구조 비교, 재구현 검증 |
|
||||
| 앱 워크플로 | 14 | 캡처한 웹 스크립트 내보내기, dyld 공유 캐시 검사, 계층 간 기능 추적, 빌드 비교, 히스토리 소스 매핑, 정적 반환 구조 비교, 재구현 검증 |
|
||||
| 작업 공간과 관찰 | 21 | 세션, 근거 번들, 탐색 컨텍스트, 프로세스·아티팩트·함수 비교, 미해결 항목 기록 |
|
||||
|
||||
## 로드맵
|
||||
@@ -315,6 +315,20 @@ REA에는 호스팅 분석 서비스가 없습니다. 로컬 Unix 소켓을 통
|
||||
|
||||
개발 환경, 아키텍처, 테스트, 릴리스 지침은 [CONTRIBUTING.md](CONTRIBUTING.md)를 참고하세요.
|
||||
|
||||
## 스타 기록
|
||||
|
||||
🎉 **GitHub 스타 20,000개, 감사합니다!**
|
||||
|
||||
REA를 사용하고, 버그를 제보하고, 빌드를 테스트하고, 수정에 기여해 주신 모든 분께 감사드립니다.
|
||||
|
||||
<a href="https://www.star-history.com/?repos=morluto%2Frea&type=date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=morluto/rea&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=morluto/rea&type=date" />
|
||||
<img alt="REA GitHub 스타 기록" src="https://api.star-history.com/chart?repos=morluto/rea&type=date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## 라이선스
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
+15
-1
@@ -214,7 +214,7 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
|
||||
| 浏览器观察 | 12 | 页面结构、网络元数据、脚本、来源映射、WebMCP 发现、截图与捕获比较 |
|
||||
| Electron 分析 | 5 | 渲染进程观察、静态应用映射、静态/运行时结果关联 |
|
||||
| JavaScript 运行时 | 2 | Node/Electron Inspector 目标发现、脚本位置与执行上下文事件 |
|
||||
| 应用工作流 | 13 | 捕获的网站脚本导出、跨层功能追踪、构建比较、历史源码映射、静态返回结构比较与重建验证 |
|
||||
| 应用工作流 | 14 | 捕获的网站脚本导出、dyld 共享缓存检查、跨层功能追踪、构建比较、历史源码映射、静态返回结构比较与重建验证 |
|
||||
| 工作区与观察 | 21 | 会话、证据包、导航上下文、进程/产物/函数比较与待解决问题跟踪 |
|
||||
|
||||
## 路线图
|
||||
@@ -316,6 +316,20 @@ REA 不提供托管分析服务,而是通过本地 Unix 套接字把操作交
|
||||
|
||||
开发环境、架构、测试和发布说明请参阅 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
||||
|
||||
## Star 历史
|
||||
|
||||
🎉 **GitHub Star 突破 20,000,感谢大家!**
|
||||
|
||||
感谢每一位使用 REA、反馈问题、测试构建和贡献修复的朋友。
|
||||
|
||||
<a href="https://www.star-history.com/?repos=morluto%2Frea&type=date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=morluto/rea&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=morluto/rea&type=date" />
|
||||
<img alt="REA GitHub Star 历史" src="https://api.star-history.com/chart?repos=morluto/rea&type=date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## 许可证
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -153,15 +153,16 @@ Resolution follows dyld:
|
||||
Each edge lists every candidate path tried, with its outcome: `resolved`,
|
||||
`absent`, `not-mach-o`, `malformed`, `unsupported` (recognized Mach-O outside
|
||||
parser coverage, such as big-endian images), `architecture-missing`,
|
||||
`outside-target`, `escapes-target`, or `undetermined`. Slices follow dyld's
|
||||
`outside-target`, `shared-cache`, `escapes-target`, or `undetermined`. Slices follow dyld's
|
||||
graded architectures, so an `x86_64h` process also loads `x86_64`. The edge's `resolution` is one of:
|
||||
|
||||
| Status | Meaning |
|
||||
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `resolved` | An image inside the analyzed root loads, and no earlier candidate was undeterminable. |
|
||||
| `conditional` | An image inside the root resolves, but an earlier candidate outside the root (for example `/usr/lib/swift`) would win if it exists on the running system. |
|
||||
| `unresolved` | Every candidate is definitively absent or unusable inside the root. |
|
||||
| `undetermined` | No candidate resolves inside the root, and some candidate lies outside it or depends on the environment. |
|
||||
| Status | Meaning |
|
||||
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `resolved` | An image inside the analyzed root loads, and no earlier candidate was undeterminable. |
|
||||
| `conditional` | An image inside the root, or in the supplied shared cache, resolves, but an earlier candidate that REA cannot decide (for example `/opt/vendor/lib` or `/usr/lib/swift` without a cache) would win if it exists on the running system. |
|
||||
| `unresolved` | Every candidate is definitively absent or unusable inside the root. |
|
||||
| `undetermined` | No candidate resolves inside the root, and some candidate lies outside it or depends on the environment. |
|
||||
| `shared-cache` | An absolute install path, or an `@rpath` candidate outside the root, was found in the dyld shared cache passed as `shared_cache` (CLI `--shared-cache`). dyld loads the cached image, so later in-root candidates are unused. |
|
||||
|
||||
A `conditional` image is still traced, because it is the image the analyzed
|
||||
root supplies. Every edge below it has `loader_conditional: true`, and a
|
||||
@@ -185,3 +186,62 @@ cache rather than on disk, so REA does not check them against the host.
|
||||
`verify:macos-bundle` checks the parser against `otool -l` for every traced
|
||||
image. It also compares the predicted load order of two process roots with the
|
||||
images dyld actually loads (`DYLD_PRINT_LIBRARIES`).
|
||||
|
||||
With `shared_cache`, absolute candidates are looked up in that cache's image
|
||||
list. A process uses the cache only when both of these hold:
|
||||
|
||||
- the cache serves its CPU family (an arm64e cache serves arm64 and arm64e);
|
||||
- one of its `LC_BUILD_VERSION` or `LC_VERSION_MIN_*` platforms (reported per
|
||||
slice as `platforms`) matches the cache's platform or alternate platform.
|
||||
|
||||
An iOS cache extracted from an IPSW is therefore not used for a macOS
|
||||
executable. A limitation names each process root the cache was not used for,
|
||||
and why. The result's `shared_cache` names the cache path, UUID, architecture
|
||||
and OS version. Cached images are not traversed.
|
||||
|
||||
An image listed in the cache but mapped in a missing or UUID-mismatched
|
||||
subcache is `undetermined`. It is listed in
|
||||
`coverage.unverified_shared_cache_images` and makes coverage partial. Without a
|
||||
cache, system paths stay `undetermined`.
|
||||
|
||||
## dyld shared cache
|
||||
|
||||
`inspect_dyld_shared_cache` reads a dyld shared cache file set selected by path.
|
||||
On current macOS this is
|
||||
`/System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld/dyld_shared_cache_arm64e`;
|
||||
a cache extracted from an IPSW also works. It runs without an open target.
|
||||
|
||||
```sh
|
||||
rea inspect-dyld-shared-cache /System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld/dyld_shared_cache_arm64e --image /usr/lib/libSystem.B.dylib --json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "inspect_dyld_shared_cache",
|
||||
"arguments": {
|
||||
"cache_path": "/System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld/dyld_shared_cache_arm64e",
|
||||
"images": ["/usr/lib/libSystem.B.dylib"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The result reports:
|
||||
|
||||
- the magic, architecture, UUID, platform and OS version, any alternate platform
|
||||
(Mac Catalyst), cache type, the development/production subtype for multi-caches
|
||||
(including its raw ID when unknown), shared region and maximum slide;
|
||||
- every VM mapping, with the file that holds it;
|
||||
- every subcache by file suffix, with its UUID checked against the main cache.
|
||||
A missing or mismatched subcache makes coverage `partial`, and images mapped
|
||||
there are `unmapped`;
|
||||
- the complete image list with addresses.
|
||||
|
||||
For each requested install path, the tool reads that image's load commands
|
||||
(install name, dependencies with their re-export, weak, upward and delayed-init
|
||||
flags, and rpaths) through the cache mappings. A path the cache does not
|
||||
contain is `absent`. Older single-file caches are read from their legacy header
|
||||
fields.
|
||||
|
||||
Nothing is executed, slid or extracted. Slide info, local symbols and the
|
||||
cache's code signature are not parsed. The Evidence subject is the main cache
|
||||
file, identified by its digest.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -100,17 +100,22 @@ a temporary output file larger than the running engine's string limit, verifies
|
||||
its bytes and digest, and removes it. Use `-- jsonl` for the compact JSONL check.
|
||||
Each check needs space for one output file plus a 1 GiB free-space reserve.
|
||||
|
||||
Other CLI formats, `--token-count`, and MCP transport serialization still
|
||||
assemble whole strings. Field selection remains useful when the caller needs a
|
||||
Other CLI formats and `--token-count` still assemble whole strings.
|
||||
Field selection remains useful when the caller needs a
|
||||
smaller view, for example `--format json --filter-output
|
||||
evidence_id,normalized_result.statistics`. Streaming output does not bound the
|
||||
memory needed to construct the analysis graph itself.
|
||||
The separate large-response transport work is tracked in
|
||||
[#1053](https://github.com/morluto/rea/issues/1053) and
|
||||
MCP prepares the complete repeated response incrementally against the pinned
|
||||
SDK's 10 MiB stdio receive-buffer budget. Oversized results return an actionable
|
||||
transport constraint and the exact same-session Evidence reference. Use
|
||||
`trace_application_feature` to inspect a selected module's relationships, or
|
||||
`export_evidence_bundle` to write the complete canonical bundle without a
|
||||
document-sized allocation. Same-session analysis reads reuse authenticated
|
||||
immutable snapshots; foreign inline Evidence is still parsed and authenticated.
|
||||
See [MCP tool results](mcp-contracts.md#tool-results) for larger client buffers
|
||||
and `REA_MCP_MAX_RESPONSE_BYTES`. Follow-up results remain complete and can also
|
||||
exceed the transport budget. Compact result views are tracked separately in
|
||||
[#1050](https://github.com/morluto/rea/issues/1050).
|
||||
Client framing limits also apply: the pinned Node MCP SDK's stdio transport
|
||||
defaults to a 10 MiB buffer. Its caller-selected `maxBufferSize` must accommodate
|
||||
the complete response, including text and structured Evidence projections.
|
||||
|
||||
## What is reconstructed
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -154,6 +164,26 @@ directly to `compare_functions`, and `inspect_artifact` Evidence to
|
||||
`compare_artifacts`. Use `get_evidence_bundle` when the task needs broader
|
||||
retained session history or an explicit bundle for transfer.
|
||||
|
||||
REA prepares complete MCP results within the pinned stdio client's 10 MiB
|
||||
receive-buffer budget, including both text and structured representations and
|
||||
room for the JSON-RPC envelope. If a result cannot fit, REA returns
|
||||
`resource_constraint` with `details.resource: "transport"` before constructing
|
||||
a document-sized string. Analysis Evidence remains complete in the current
|
||||
session. Its exact reference is reported in
|
||||
`details.reported_limits.evidence_reference`; use it with a focused application
|
||||
workflow, or call `export_evidence_bundle` with a destination path. Complete
|
||||
bundle exports stream canonical JSON into an atomically published file. A broad
|
||||
follow-up or `get_evidence_bundle` can also exceed the response budget; exporting
|
||||
preserves the complete session without sending it through a single MCP frame.
|
||||
|
||||
Clients that explicitly configure a larger receive buffer can set the REA
|
||||
server's `REA_MCP_MAX_RESPONSE_BYTES` environment variable to the same byte
|
||||
count. This setting must be a safe decimal integer at least 10485760; REA
|
||||
reserves 1024 bytes for the envelope. Raising it restores complete inline
|
||||
delivery for responses that fit that buffer and Node's single-string limit.
|
||||
It does not change the client's buffer, analysis coverage, or retained content.
|
||||
Ordinary responses keep their existing complete result contract.
|
||||
|
||||
## Retained application Evidence inputs
|
||||
|
||||
`trace_application_feature`, `trace_javascript_semantics`,
|
||||
|
||||
@@ -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": "9ee5392d18f04f25b5c97c1b69a3644a0d81e52c813167a6f140791bc692741d",
|
||||
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
|
||||
"combined_sha256": "4c5f67d076baf821b5cb6d6fb2cc0f6c274e96b627b85b892577760a6f71ab1a",
|
||||
"providers_sha256": "954b499c76514890062d2b6c936d73cb3a9c45412d522a081125a325e81cdec5"
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
-5
@@ -11,6 +11,32 @@ breaking changes. Record the full source SHA and create `release/VERSION` at
|
||||
that commit. The version is a maintainer choice; Release Please's proposed
|
||||
version must agree before publication.
|
||||
|
||||
`release/VERSION` also sets the expected version for the workflow. Use an exact
|
||||
SemVer such as `release/6.1.0` or `release/6.1.0-rc.1`, without build metadata.
|
||||
The checkpoint validator rejects a candidate whose package, lockfile, manifest,
|
||||
registry metadata or changelog disagrees with that version before creating a tag.
|
||||
|
||||
### Compatibility and version selection
|
||||
|
||||
REA uses the default SemVer strategy: compatible fixes increment patch;
|
||||
compatible capabilities increment minor; incompatible public behavior increments
|
||||
major. Adding tools, internal refactoring and correcting behavior outside the
|
||||
supported contract do not by themselves require a major release.
|
||||
|
||||
The supported public surface includes documented CLI commands/options, MCP tool
|
||||
names and input/result contracts, saved evidence formats and supported runtime
|
||||
requirements. Before adding `!` or a `BREAKING CHANGE` footer, a PR must identify
|
||||
a previously valid call or configuration that will fail or change meaning,
|
||||
explain why compatibility cannot be preserved, and give its migration. Prefer
|
||||
optional additions, compatibility adapters and a documented deprecation period;
|
||||
group deliberate removals into a planned major release. Maintainers review this
|
||||
impact before merging; the bot parses markers and cannot infer compatibility.
|
||||
|
||||
For example, adding an optional inspection tool is a minor change. Rejecting a
|
||||
relative MCP path previously accepted by the public contract is breaking;
|
||||
rejecting an input that the existing contract already prohibited is a fix.
|
||||
Changing the version number alone does not restore compatibility.
|
||||
|
||||
For example, after selecting a reviewed commit for 5.0.0:
|
||||
|
||||
```bash
|
||||
@@ -48,6 +74,24 @@ normalization, then approve that head's blocked runs from the PR page. Updates
|
||||
to the bot branch can create new approval-required runs; approval of an older
|
||||
head does not verify the normalized candidate. Wait for the final head's CI.
|
||||
|
||||
The validator reads the actual ancestry range from the published baseline tag
|
||||
to the selected checkpoint, including visible first-parent Conventional Commits
|
||||
and the PR titles in GitHub's default merge messages. The report preserves the
|
||||
original merge subject alongside the extracted Conventional Commit title.
|
||||
This matters after merging a side-branch release back into a newer main:
|
||||
Release Please's chronological history cutoff can omit unreleased mainline
|
||||
commits. Unreleased breaking markers require a major increment and references
|
||||
in the new release's breaking-change migration section. Other omitted entries
|
||||
are reported for review. Historical notes from an older release cannot satisfy
|
||||
the new release's migration check. This audits declared markers and references;
|
||||
it does not prove API compatibility or the quality of a migration explanation.
|
||||
|
||||
If preparation reports a mismatch or omitted breaking changes, inspect the
|
||||
recorded ancestry report, correct the release PR's version artifacts and notes,
|
||||
regenerate documentation, and review/test its final head. Do not repeatedly
|
||||
prepare over manual corrections. Publication independently checks the merged
|
||||
candidate again before Release Please can create a tag.
|
||||
|
||||
Review the candidate's version, notes, generated metadata, and package
|
||||
contents. Wait for the candidate's CI and relevant real-provider checks.
|
||||
Routine local iterations need focused checks; CI owns full deterministic
|
||||
@@ -100,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.
|
||||
|
||||
@@ -119,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
|
||||
|
||||
|
||||
+17
-9
@@ -98,6 +98,12 @@ an XML graph golden. `verify:asset-catalog` compiles source-owned colors with
|
||||
`actool`, invokes real `assetutil`, then checks CLI/MCP results, exact catalog
|
||||
digest, every raw metadata field, pagination and malformed input rejection.
|
||||
Neither artifact workflow requires Hopper or launches it. Both run in macOS CI.
|
||||
`verify:dyld-shared-cache` reads the host's dyld shared cache. It checks that
|
||||
the image list, and every image's dependencies with their attributes, equal
|
||||
`dyld_info -all_dyld_cache -linked_dylibs`, and that `inspect-dyld-shared-cache`
|
||||
agrees between CLI and stdio MCP. It then traces the clang-built fixture app
|
||||
with `--shared-cache` and checks that every `shared-cache` edge names a library
|
||||
dyld actually loads under `DYLD_PRINT_LIBRARIES`.
|
||||
`verify:macos-bundle` needs only macOS with Command Line Tools. It compiles a
|
||||
source-owned app with `clang`: a versioned framework, XPC services, an app
|
||||
extension, a login item, a privileged helper, launchd plists, and a helper tool,
|
||||
@@ -425,7 +431,7 @@ behavior, and complete CI evidence before merging.
|
||||
| `npm run test:watch:all` | Changed tests from every project; builds at startup, so rebuild after production edits before relying on compiled tests |
|
||||
| `npm run check:changed` | Cached typecheck/lint and branch-related source feedback |
|
||||
| `npm run check:pr` | Opt-in complete local deterministic gate and generated-file checks |
|
||||
| `npm run docs:check` | Committed generated-document freshness |
|
||||
| `npm run docs:check` | Generated-document validation from current source and build outputs |
|
||||
|
||||
For example:
|
||||
|
||||
@@ -460,19 +466,21 @@ broad changes or diagnosing CI, rather than after every edit. Package/install
|
||||
changes additionally need package verification; provider changes need actual
|
||||
provider evidence.
|
||||
|
||||
Local full-suite Vitest runs use up to two workers and schedule projects one
|
||||
at a time. Process, acceptance and process-global projects serialize their
|
||||
files to prevent competing lifecycle observations. CI retains its two-worker
|
||||
budget.
|
||||
Each Vitest project uses up to two workers, bounded by available host parallelism.
|
||||
Default-group projects can run together. Only `process-boundary` runs in the
|
||||
later sequence group and serializes its files because process-tree observations
|
||||
share host sampling resources. Acceptance and process-global files retain isolated
|
||||
forks but are not serialized; isolation does not imply serial scheduling. CI uses
|
||||
the same per-project worker bound.
|
||||
The pure domain/contracts and recording-port service projects share one worker
|
||||
module context because their tests own no mutable runtime resources. MCP
|
||||
boundary files also share the immutable server module graph while creating and
|
||||
closing independent in-memory sessions. Adapter, composition, acceptance,
|
||||
process-global, and other boundary projects retain per-file isolation.
|
||||
`npm test`, `npm run docs:check`, and `npm run docs:generate` share
|
||||
repository-local locks and fail fast when the same class of command is already
|
||||
running. The `npm test` build is inside that lock. `check:pr` runs its test task
|
||||
before starting generated-document validation.
|
||||
Build and documentation writers use checkout-local locks for their respective
|
||||
output files. `npm test` delegates its build to the build lock; the Vitest suite
|
||||
does not hold a test-command lock. `check:pr` runs its test task before starting
|
||||
generated-document validation.
|
||||
|
||||
Vitest and Node persistent compile caches are deliberately not enabled by
|
||||
default. To evaluate repeated local runs, opt in for both cold and warm
|
||||
|
||||
+12
-1
@@ -80,7 +80,18 @@ or model-authored loop when one call can answer the question.
|
||||
## Implement and evaluate
|
||||
|
||||
- Put shared user workflows in the application layer; keep MCP translation in
|
||||
the server adapter and CLI behavior aligned with the same workflow.
|
||||
the server adapter and CLI behavior aligned with the same workflow. Share
|
||||
Evidence provenance, unknown projection, and eligible snapshot binding through
|
||||
application/session owners. Matching result payloads alone do not establish
|
||||
equivalent retention or replay behavior.
|
||||
- Derive each facet's coverage from examined records, unsupported representations,
|
||||
and exhaustiveness. Keep issues structured until presentation; do not select a
|
||||
facet by matching diagnostic prose or infer completeness from an empty failure
|
||||
list. Preserve available observations independently of cleanup success.
|
||||
- Measure the resource consumed before choosing a limit. Value alternatives,
|
||||
retained records, trace-frame products, output bytes, and temporary-file growth
|
||||
need different accounting. Enforce a justified budget before the costly step
|
||||
and report its effect; an input-byte or graph-node cap does not bound every stage.
|
||||
- Update the canonical contract inventory, output schemas, examples, generated
|
||||
catalog, and relevant docs together.
|
||||
- Verify valid, malformed, boundary, unavailable, cancellation, and partial
|
||||
|
||||
@@ -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_8a67e7a3578e49c8d8d22107eab8f1258094dc40041d14deee93189fd0c514b2",
|
||||
"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": "ef398cf4b5ee7fb5e0bcf42ca787049ec145e1d911ee7951017fee1cb3980d74"
|
||||
}
|
||||
],
|
||||
"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;
|
||||
|
||||
+1
-1
@@ -107,7 +107,7 @@ if [[ "$dry_run" == true ]]; then
|
||||
fi
|
||||
|
||||
printf 'Installing %s@%s...\n' "$PACKAGE" "$version"
|
||||
npm install --global "${prefix_args[@]}" "$PACKAGE@$version" || fail "npm could not install REA. Check registry access and npm permissions, then retry."
|
||||
npm install --global ${prefix_args[@]+"${prefix_args[@]}"} "$PACKAGE@$version" || fail "npm could not install REA. Check registry access and npm permissions, then retry."
|
||||
[[ -x "$install_bin" ]] || fail "npm completed without installing the rea command. Check the npm global bin directory and PATH, then retry."
|
||||
installed_version="$("$install_bin" --version 2>/dev/null | tr -d '[:space:]')" || fail "the installed REA version could not be read. Reinstall the requested version, then retry."
|
||||
[[ "$installed_version" == "$version" ]] || fail "installed version $installed_version does not match $version. Reinstall the requested version, then retry."
|
||||
|
||||
+6
-2
@@ -87,7 +87,8 @@
|
||||
"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 && npm run build:projections",
|
||||
"build:projections": "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",
|
||||
@@ -141,6 +142,7 @@
|
||||
"verify:apple-dispatch": "npm run build:cached && node scripts/verify/apple/dispatch.mjs",
|
||||
"verify:native-ui": "npm run build:cached && node scripts/verify-native-ui.mjs",
|
||||
"verify:keyed-archive": "npm run build:cached && node scripts/verify/apple/keyed-archive.mjs",
|
||||
"verify:dyld-shared-cache": "npm run build:cached && node scripts/verify/apple/dyld-shared-cache.mjs",
|
||||
"verify:macos-bundle": "npm run build:cached && node scripts/verify/apple/macos-bundle.mjs",
|
||||
"verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs",
|
||||
"verify:ida": "npm run build:cached && node scripts/verify-real-ida.mjs",
|
||||
@@ -179,11 +181,13 @@
|
||||
"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",
|
||||
"docs:generate:unlocked": "npm run metadata:generate && npm run build:cached && node scripts/generate-skill-metadata.mjs && node scripts/generate-product-catalog.mjs && node scripts/generate-completion-ledger.mjs && node scripts/generate-error-schema.mjs",
|
||||
"docs:generate:unlocked": "npm run metadata:generate && npm run build:cached && node scripts/generate-error-schema.mjs",
|
||||
"docs:check": "node scripts/run-exclusive.mjs docs turbo run docs:check:built",
|
||||
"docs:check:built": "node scripts/generate-package-metadata.mjs --check && node scripts/generate-mcp-tool-catalog.mjs --check && node scripts/generate-skill-metadata.mjs --check && node scripts/generate-product-catalog.mjs --check && node scripts/generate-completion-ledger.mjs --check && node scripts/check-doc-facts.mjs && node scripts/generate-error-schema.mjs --check",
|
||||
"docs:error-schema": "npm run build:cached && node scripts/generate-error-schema.mjs",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export async function artifactCliEvidence(command, target, arguments_ = []) {
|
||||
return evidence;
|
||||
}
|
||||
|
||||
/** Run a real stdio MCP subprocess, owning and closing its transport. */
|
||||
/** Run a real stdio MCP subprocess, owning and closing its transport; open `target` unless it is null. */
|
||||
export async function withArtifactMcp(target, verify) {
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
@@ -49,11 +49,14 @@ export async function withArtifactMcp(target, verify) {
|
||||
const client = new Client({ name: "artifact-real-e2e", version: "1" });
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const opened = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: target },
|
||||
});
|
||||
assert.notEqual(opened.isError, true, JSON.stringify(opened));
|
||||
// Target-free tools pass `null` and need no open session.
|
||||
if (target !== null) {
|
||||
const opened = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: target },
|
||||
});
|
||||
assert.notEqual(opened.isError, true, JSON.stringify(opened));
|
||||
}
|
||||
await verify(client);
|
||||
} finally {
|
||||
await client.close();
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { chromium } from "playwright-core";
|
||||
@@ -62,13 +65,21 @@ export async function verifyLargeScreenshotE2e(endpoint, origin) {
|
||||
);
|
||||
assert.equal(bytes.readUInt32BE(16), 2048);
|
||||
assert.equal(bytes.readUInt32BE(20), 1536);
|
||||
await verifyDefaultScreenshotDelivery({
|
||||
entrypoint,
|
||||
environment: env,
|
||||
endpoint,
|
||||
targetId: target.id,
|
||||
screenshot,
|
||||
});
|
||||
const maxBufferSize = 64 * 1024 * 1024;
|
||||
transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
env,
|
||||
env: { ...env, REA_MCP_MAX_RESPONSE_BYTES: String(maxBufferSize) },
|
||||
stderr: "pipe",
|
||||
// The SDK defaults to a 10 MiB receive buffer; inline PNG output is larger.
|
||||
maxBufferSize: 64 * 1024 * 1024,
|
||||
maxBufferSize,
|
||||
});
|
||||
client = new Client({ name: "browser-screenshot-real-e2e", version: "1" });
|
||||
await client.connect(transport);
|
||||
@@ -102,6 +113,9 @@ export async function verifyLargeScreenshotE2e(endpoint, origin) {
|
||||
mocked: false,
|
||||
cli: true,
|
||||
stdio_mcp: true,
|
||||
default_buffer_recovery: true,
|
||||
complete_export: true,
|
||||
matched_large_buffer: true,
|
||||
png_bytes: bytes.byteLength,
|
||||
};
|
||||
} finally {
|
||||
@@ -117,3 +131,73 @@ export async function verifyLargeScreenshotE2e(endpoint, origin) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const verifyDefaultScreenshotDelivery = async ({
|
||||
entrypoint,
|
||||
environment,
|
||||
endpoint,
|
||||
targetId,
|
||||
screenshot,
|
||||
}) => {
|
||||
const workspace = await mkdtemp(join(tmpdir(), "rea-screenshot-delivery-"));
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
env: environment,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const client = new Client({
|
||||
name: "browser-screenshot-default-e2e",
|
||||
version: "1",
|
||||
});
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const constrained = await client.callTool(
|
||||
{
|
||||
name: "capture_web_screenshot",
|
||||
arguments: { cdp_endpoint: endpoint, target_id: targetId },
|
||||
},
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
assert.equal(constrained.isError, true);
|
||||
assert.equal(
|
||||
constrained.structuredContent?.error?.details?.resource,
|
||||
"transport",
|
||||
);
|
||||
const reference =
|
||||
constrained.structuredContent?.error?.details?.reported_limits
|
||||
?.evidence_reference;
|
||||
assert.equal(reference?.kind, "retained-evidence");
|
||||
await client.ping();
|
||||
const exported = await client.callTool({
|
||||
name: "export_evidence_bundle",
|
||||
arguments: { path: join(workspace, "screenshot-evidence.json") },
|
||||
});
|
||||
assert.notEqual(exported.isError, true, JSON.stringify(exported));
|
||||
const bundle = JSON.parse(
|
||||
await readFile(join(workspace, "screenshot-evidence.json"), "utf8"),
|
||||
);
|
||||
const record = bundle.records.find(
|
||||
(record) => record.evidence_id === reference.evidence_id,
|
||||
);
|
||||
assert.ok(
|
||||
record,
|
||||
"Full screenshot Evidence must remain exportable after the delivery constraint",
|
||||
);
|
||||
const retained = webScreenshotSchema.parse(
|
||||
parseEvidence(record).normalized_result,
|
||||
);
|
||||
assert.deepEqual(retained.artifact, screenshot.artifact);
|
||||
await client.ping();
|
||||
} finally {
|
||||
try {
|
||||
await client.close();
|
||||
} finally {
|
||||
try {
|
||||
await transport.close();
|
||||
} finally {
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -243,7 +243,8 @@ export const providerCatalog = (sources) => {
|
||||
contracts: sources.applicationContracts.APPLICATION_TOOL_CONTRACTS.filter(
|
||||
({ name }) =>
|
||||
name !== "project_android_application_graph" &&
|
||||
name !== "project_apple_application_graph",
|
||||
name !== "project_apple_application_graph" &&
|
||||
name !== "inspect_dyld_shared_cache",
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -255,7 +256,9 @@ export const providerCatalog = (sources) => {
|
||||
{
|
||||
identity: sources.artifactProviders.APPLE_APPLICATION_PROVIDER,
|
||||
contracts: sources.applicationContracts.APPLICATION_TOOL_CONTRACTS.filter(
|
||||
({ name }) => name === "project_apple_application_graph",
|
||||
({ name }) =>
|
||||
name === "project_apple_application_graph" ||
|
||||
name === "inspect_dyld_shared_cache",
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -10,7 +10,10 @@ export const withLargeResultMcp = async (
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
env: environment,
|
||||
env: {
|
||||
...environment,
|
||||
REA_MCP_MAX_RESPONSE_BYTES: String(maxBufferSize),
|
||||
},
|
||||
stderr: "inherit",
|
||||
maxBufferSize,
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
import { gt, major, valid } from "semver";
|
||||
import { z } from "zod";
|
||||
|
||||
const shaSchema = z.string().regex(/^[a-f0-9]{40}$/u);
|
||||
const versionSchema = z.object({ version: z.string() });
|
||||
const manifestSchema = z.object({ ".": z.string() });
|
||||
const sectionsSchema = z.object({
|
||||
"changelog-sections": z.array(
|
||||
z.object({ type: z.string(), hidden: z.boolean().optional() }),
|
||||
),
|
||||
});
|
||||
const GITHUB_MERGE_SUBJECT = /^Merge pull request #(\d+) from \S+/u;
|
||||
|
||||
function conventionalTitle(subject, body) {
|
||||
if (!GITHUB_MERGE_SUBJECT.test(subject)) return subject;
|
||||
// GitHub's default merge message puts the PR title after its subject.
|
||||
// Inspect that first nonempty line, not arbitrary examples in commit prose.
|
||||
return (
|
||||
body
|
||||
.split(/\r?\n/u)
|
||||
.slice(1)
|
||||
.find((line) => line.trim() !== "")
|
||||
?.trim() ?? subject
|
||||
);
|
||||
}
|
||||
|
||||
function releaseVersion(value, location) {
|
||||
if (valid(value) !== value || value.includes("+")) {
|
||||
throw new Error(
|
||||
`${location} must contain an exact SemVer without build metadata: ${value}`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function readJson(git, sha, path, schema) {
|
||||
const text = await git(["show", `${sha}:${path}`]);
|
||||
let value;
|
||||
try {
|
||||
value = JSON.parse(text);
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
throw new Error(`${path}@${sha} contains invalid JSON: ${error.message}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const parsed = schema.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
throw new Error(`${path}@${sha} is malformed: ${parsed.error.message}`);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function readCommits(text) {
|
||||
if (text.length === 0) return [];
|
||||
const fields = text.split("\0");
|
||||
if (fields.pop() !== "" || fields.length % 3 !== 0) {
|
||||
throw new Error("Git returned malformed checkpoint history records.");
|
||||
}
|
||||
const commits = [];
|
||||
for (let index = 0; index < fields.length; index += 3) {
|
||||
const sha = shaSchema.parse(fields[index]);
|
||||
const subject = z.string().parse(fields[index + 1]);
|
||||
const body = z.string().parse(fields[index + 2]);
|
||||
const title = conventionalTitle(subject, body);
|
||||
const type = /^([a-z]+)(?:\([^)]*\))?!?: /u.exec(title)?.[1];
|
||||
commits.push({
|
||||
sha,
|
||||
subject,
|
||||
...(title !== subject ? { conventionalTitle: title } : {}),
|
||||
type,
|
||||
breaking:
|
||||
/^[a-z]+(?:\([^)]*\))?!: /u.test(title) ||
|
||||
/^BREAKING(?: CHANGE|-CHANGE):\s*\S/mu.test(body),
|
||||
});
|
||||
}
|
||||
return commits;
|
||||
}
|
||||
|
||||
function referencesCommit(text, commit) {
|
||||
if (text.includes(commit.sha.slice(0, 7))) return true;
|
||||
const pullRequest =
|
||||
GITHUB_MERGE_SUBJECT.exec(commit.subject)?.[1] ??
|
||||
/\(#(\d+)\)$/u.exec(commit.subject)?.[1];
|
||||
return (
|
||||
pullRequest !== undefined &&
|
||||
new RegExp(`#${pullRequest}(?!\\d)`, "u").test(text)
|
||||
);
|
||||
}
|
||||
|
||||
function releaseNotes(text, expectedVersion) {
|
||||
const headings = Array.from(text.matchAll(/^## \[([^\]]+)\].*$/gmu));
|
||||
const first = headings[0];
|
||||
if (first?.[1] !== expectedVersion || first.index === undefined) {
|
||||
throw new Error(`CHANGELOG.md must start with release ${expectedVersion}.`);
|
||||
}
|
||||
return text.slice(first.index, headings[1]?.index ?? text.length);
|
||||
}
|
||||
|
||||
function breakingSection(notes) {
|
||||
const heading = /^### [^\n]*BREAKING CHANGES[^\n]*\n/imu.exec(notes);
|
||||
if (heading === null) return "";
|
||||
const body = notes.slice(heading.index + heading[0].length);
|
||||
const end = body.search(/^### /mu);
|
||||
return end < 0 ? body : body.slice(0, end);
|
||||
}
|
||||
|
||||
async function requireAncestor(git, ancestor, descendant, description) {
|
||||
try {
|
||||
await git(["merge-base", "--is-ancestor", ancestor, descendant]);
|
||||
} catch (error) {
|
||||
const failure = z.object({ code: z.literal(1) }).safeParse(error);
|
||||
if (!failure.success) throw error;
|
||||
throw new Error(
|
||||
`${description}: ${ancestor} is not an ancestor of ${descendant}. Sync the released tag into main before cutting a checkpoint, and prepare candidates from that frozen source.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveBaseline(git, tag) {
|
||||
try {
|
||||
return shaSchema.parse(
|
||||
(await git(["rev-parse", "--verify", `${tag}^{commit}`])).trim(),
|
||||
);
|
||||
} catch (error) {
|
||||
const failure = z.object({ code: z.literal(128) }).safeParse(error);
|
||||
if (!failure.success) throw error;
|
||||
throw new Error(
|
||||
`Published baseline ${tag} is unavailable. Fetch release tags and finish the previous release sync before preparing this checkpoint. ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateCandidate(git, sha, version, commits) {
|
||||
const versions = [
|
||||
[
|
||||
"package.json",
|
||||
(await readJson(git, sha, "package.json", versionSchema)).version,
|
||||
],
|
||||
[
|
||||
".release-please-manifest.json",
|
||||
(
|
||||
await readJson(
|
||||
git,
|
||||
sha,
|
||||
".release-please-manifest.json",
|
||||
manifestSchema,
|
||||
)
|
||||
)["."],
|
||||
],
|
||||
];
|
||||
const lock = await readJson(
|
||||
git,
|
||||
sha,
|
||||
"package-lock.json",
|
||||
versionSchema.extend({
|
||||
packages: z.object({ "": versionSchema }),
|
||||
}),
|
||||
);
|
||||
versions.push(
|
||||
["package-lock.json", lock.version],
|
||||
["package-lock.json packages.root", lock.packages[""].version],
|
||||
);
|
||||
const server = await readJson(
|
||||
git,
|
||||
sha,
|
||||
"server.json",
|
||||
versionSchema.extend({
|
||||
packages: z.array(
|
||||
z.object({ identifier: z.string(), version: z.string() }),
|
||||
),
|
||||
}),
|
||||
);
|
||||
const npmPackage = server.packages.find(
|
||||
(entry) => entry.identifier === "rea-agents",
|
||||
);
|
||||
if (npmPackage === undefined)
|
||||
throw new Error("server.json is missing the rea-agents package.");
|
||||
versions.push(
|
||||
["server.json", server.version],
|
||||
["server.json rea-agents package", npmPackage.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`,
|
||||
]);
|
||||
const generatedVersions = Array.from(
|
||||
generated.matchAll(/^\s+version: "([^"]+)"/gmu),
|
||||
);
|
||||
if (generatedVersions.length !== 1)
|
||||
throw new Error(
|
||||
"Generated package metadata must declare one literal version.",
|
||||
);
|
||||
versions.push(["src/generatedPackageMetadata.ts", generatedVersions[0]?.[1]]);
|
||||
for (const [location, actual] of versions) {
|
||||
if (actual !== version) {
|
||||
throw new Error(
|
||||
`${location}@${sha} declares ${actual}; release branch requires ${version}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const notes = releaseNotes(
|
||||
await git(["show", `${sha}:CHANGELOG.md`]),
|
||||
version,
|
||||
);
|
||||
const breakingNotes = breakingSection(notes);
|
||||
const missingBreaking = commits.filter(
|
||||
(commit) => commit.breaking && !referencesCommit(breakingNotes, commit),
|
||||
);
|
||||
if (missingBreaking.length > 0) {
|
||||
throw new Error(
|
||||
`Release ${version} migration notes omit unreleased breaking changes: ${missingBreaking.map((commit) => `${commit.sha} ${commit.subject}`).join("; ")}`,
|
||||
);
|
||||
}
|
||||
return commits.filter((commit) => !referencesCommit(notes, commit));
|
||||
}
|
||||
|
||||
/** Validate a recorded checkpoint and optional candidate using exact Git ancestry. */
|
||||
export async function inspectReleaseCheckpoint(git, options) {
|
||||
const { phase, stage, releaseBranch, sourceSha, candidateSha } = z
|
||||
.object({
|
||||
phase: z.enum(["prepare", "publish"]),
|
||||
stage: z.enum(["source", "candidate"]),
|
||||
releaseBranch: z.string().startsWith("release/"),
|
||||
sourceSha: shaSchema,
|
||||
candidateSha: shaSchema.optional(),
|
||||
})
|
||||
.parse(options);
|
||||
const expectedVersion = releaseVersion(
|
||||
releaseBranch.slice("release/".length),
|
||||
"release/VERSION",
|
||||
);
|
||||
if (phase === "publish" && stage === "candidate") {
|
||||
throw new Error(
|
||||
"Publish validates its merged source SHA; candidate stage applies only to prepare.",
|
||||
);
|
||||
}
|
||||
const applicationSha =
|
||||
phase === "publish"
|
||||
? shaSchema.parse((await git(["rev-parse", `${sourceSha}^1`])).trim())
|
||||
: sourceSha;
|
||||
const manifest = await readJson(
|
||||
git,
|
||||
applicationSha,
|
||||
".release-please-manifest.json",
|
||||
manifestSchema,
|
||||
);
|
||||
const baselineVersion = releaseVersion(
|
||||
manifest["."],
|
||||
"published baseline manifest",
|
||||
);
|
||||
const sourcePackage = await readJson(
|
||||
git,
|
||||
applicationSha,
|
||||
"package.json",
|
||||
versionSchema,
|
||||
);
|
||||
if (sourcePackage.version !== baselineVersion) {
|
||||
throw new Error(
|
||||
`Source package ${sourcePackage.version} and release baseline ${baselineVersion} disagree.`,
|
||||
);
|
||||
}
|
||||
if (!gt(expectedVersion, baselineVersion)) {
|
||||
throw new Error(
|
||||
`Expected release ${expectedVersion} must advance published baseline ${baselineVersion}.`,
|
||||
);
|
||||
}
|
||||
const baselineTag = `refs/tags/rea-agents-${baselineVersion}`;
|
||||
const baselineSha = await resolveBaseline(git, baselineTag);
|
||||
await requireAncestor(
|
||||
git,
|
||||
baselineSha,
|
||||
applicationSha,
|
||||
"Published baseline is outside the checkpoint ancestry",
|
||||
);
|
||||
const configuration = await readJson(
|
||||
git,
|
||||
applicationSha,
|
||||
"release-please-config.json",
|
||||
sectionsSchema,
|
||||
);
|
||||
const visibleTypes = new Set(
|
||||
configuration["changelog-sections"]
|
||||
.filter((section) => !section.hidden)
|
||||
.map((section) => section.type),
|
||||
);
|
||||
const commits = readCommits(
|
||||
await git([
|
||||
"log",
|
||||
"--first-parent",
|
||||
"-z",
|
||||
"--format=%H%x00%s%x00%B",
|
||||
`${baselineSha}..${applicationSha}`,
|
||||
]),
|
||||
).filter((commit) => commit.breaking || visibleTypes.has(commit.type));
|
||||
const breaking = commits.filter((commit) => commit.breaking);
|
||||
if (
|
||||
breaking.length > 0 &&
|
||||
major(baselineVersion) > 0 &&
|
||||
major(expectedVersion) <= major(baselineVersion)
|
||||
) {
|
||||
throw new Error(
|
||||
`Expected release ${expectedVersion} contains unreleased breaking markers since ${baselineVersion}: ${breaking.map((commit) => `${commit.sha} ${commit.subject}`).join("; ")}. Review compatibility or select a major version.`,
|
||||
);
|
||||
}
|
||||
let snapshot = phase === "publish" ? sourceSha : candidateSha;
|
||||
if (stage === "candidate" && snapshot === undefined) {
|
||||
snapshot = shaSchema.parse((await git(["rev-parse", "HEAD"])).trim());
|
||||
}
|
||||
if (phase === "publish" || stage === "candidate") {
|
||||
await requireAncestor(
|
||||
git,
|
||||
applicationSha,
|
||||
snapshot,
|
||||
"Candidate is outside the selected checkpoint ancestry",
|
||||
);
|
||||
}
|
||||
const missingNotes =
|
||||
phase === "publish" || stage === "candidate"
|
||||
? await validateCandidate(git, snapshot, expectedVersion, commits)
|
||||
: [];
|
||||
return {
|
||||
expectedVersion,
|
||||
baselineVersion,
|
||||
baselineSha,
|
||||
sourceSha,
|
||||
applicationSha,
|
||||
commits,
|
||||
missingNotes,
|
||||
};
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFile } from "node:child_process";
|
||||
import { parseArgs, promisify } from "node:util";
|
||||
import { inspectReleaseCheckpoint } from "./lib/release-checkpoint.mjs";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
async function git(args) {
|
||||
const result = await execFileAsync("git", args, {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
phase: { type: "string" },
|
||||
stage: { type: "string", default: "source" },
|
||||
"release-branch": { type: "string" },
|
||||
"source-sha": { type: "string" },
|
||||
},
|
||||
});
|
||||
const report = await inspectReleaseCheckpoint(git, {
|
||||
phase: values.phase,
|
||||
stage: values.stage,
|
||||
releaseBranch: values["release-branch"],
|
||||
sourceSha: values["source-sha"],
|
||||
});
|
||||
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||
if (report.missingNotes.length > 0) {
|
||||
process.stderr.write(
|
||||
`Review ${report.missingNotes.length} additional unreleased mainline entries missing from these release notes; see the ancestry report above.\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await main().catch((error) => {
|
||||
process.stderr.write(
|
||||
`Release checkpoint validation failed: ${error instanceof Error ? error.message : String(error)}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { access, mkdtemp, open, realpath, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { DyldSharedCache } from "../../../dist/artifacts/apple/DyldSharedCacheReader.js";
|
||||
import {
|
||||
artifactCli,
|
||||
artifactMcpResult,
|
||||
withArtifactMcp,
|
||||
} from "../../lib/artifact-e2e.mjs";
|
||||
import {
|
||||
buildMacosBundleFixture,
|
||||
preflightMacosBundleFixture,
|
||||
} from "../../fixtures/apple/macos-bundle.mjs";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
|
||||
await preflightMacosBundleFixture();
|
||||
try {
|
||||
await exec("/usr/bin/xcrun", ["--find", "dyld_info"]);
|
||||
} catch (cause) {
|
||||
throw new Error(
|
||||
"dyld shared cache verification requires dyld_info from Command Line Tools (xcrun --find dyld_info failed)",
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
|
||||
/** The cache this host's dyld uses for native processes. */
|
||||
const hostCache = async () => {
|
||||
const names =
|
||||
process.arch === "arm64"
|
||||
? ["dyld_shared_cache_arm64e"]
|
||||
: ["dyld_shared_cache_x86_64h", "dyld_shared_cache_x86_64"];
|
||||
for (const directory of [
|
||||
"/System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld",
|
||||
"/System/Library/dyld",
|
||||
])
|
||||
for (const name of names)
|
||||
try {
|
||||
await access(join(directory, name));
|
||||
return join(directory, name);
|
||||
} catch {
|
||||
// Try the next documented location.
|
||||
}
|
||||
throw new Error("No dyld shared cache was found in the documented locations");
|
||||
};
|
||||
|
||||
/** `dyld_info -all_dyld_cache -linked_dylibs`: path -> "attributes|load path" lines. */
|
||||
const dyldInfoImages = async () => {
|
||||
const { stdout } = await exec(
|
||||
"/usr/bin/xcrun",
|
||||
["dyld_info", "-all_dyld_cache", "-linked_dylibs"],
|
||||
{ maxBuffer: 256 * 1024 * 1024 },
|
||||
);
|
||||
const images = new Map();
|
||||
let current;
|
||||
for (const line of stdout.split("\n")) {
|
||||
const head = /^(\/.+) \[(\S+)\]:$/u.exec(line);
|
||||
if (head !== null) {
|
||||
current = [];
|
||||
images.set(head[1], current);
|
||||
continue;
|
||||
}
|
||||
const dependency = /^\s{8}(.*?)\s*(\/\S.*)$/u.exec(line);
|
||||
if (dependency !== null && current !== undefined)
|
||||
current.push(
|
||||
`${dependency[1].split(/\s+/u).filter(Boolean).sort().join(",")}|${dependency[2]}`,
|
||||
);
|
||||
}
|
||||
return images;
|
||||
};
|
||||
|
||||
const attributes = (dependency) =>
|
||||
[
|
||||
dependency.reexport ? "re-export" : null,
|
||||
dependency.weak ? "weak-link" : null,
|
||||
dependency.upward ? "upward" : null,
|
||||
dependency.delayed_init ? "delay-init" : null,
|
||||
]
|
||||
.filter((value) => value !== null)
|
||||
.sort()
|
||||
.join(",");
|
||||
|
||||
const cachePath = await hostCache();
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-dyld-cache-"));
|
||||
try {
|
||||
const expected = await dyldInfoImages();
|
||||
const cache = await DyldSharedCache.open(cachePath);
|
||||
let compared = 0;
|
||||
try {
|
||||
assert.deepEqual(
|
||||
cache.images.map(({ path }) => path).sort(),
|
||||
[...expected.keys()].sort(),
|
||||
"image list differs from dyld_info",
|
||||
);
|
||||
for (const [path, dependencies] of expected) {
|
||||
const read = await cache.imageFacts(path);
|
||||
assert.equal(read?.facts.status, "parsed", path);
|
||||
assert.deepEqual(
|
||||
read.facts.slices[0].dependencies.map(
|
||||
(dependency) =>
|
||||
`${attributes(dependency)}|${dependency.install_name}`,
|
||||
),
|
||||
dependencies,
|
||||
`${path} dependencies differ from dyld_info`,
|
||||
);
|
||||
compared += 1;
|
||||
}
|
||||
} finally {
|
||||
await cache.close();
|
||||
}
|
||||
|
||||
const inspected = await artifactCli("inspect-dyld-shared-cache", cachePath, [
|
||||
"--image",
|
||||
"/usr/lib/libSystem.B.dylib",
|
||||
]);
|
||||
assert.equal(inspected.images_total, expected.size);
|
||||
assert.equal(inspected.coverage.status, "complete");
|
||||
assert.equal(inspected.inspected_images[0].status, "parsed");
|
||||
// Check the producer header directly, independently of REA's header projection.
|
||||
const handle = await open(cachePath, "r");
|
||||
try {
|
||||
const header = Buffer.alloc(0x1cc);
|
||||
const { bytesRead } = await handle.read(header, 0, header.length, 0);
|
||||
const multi = header.readBigUInt64LE(0x68) === 2n;
|
||||
const hasSubtype =
|
||||
bytesRead === header.length && header.readUInt32LE(0x10) >= header.length;
|
||||
const subtype = multi && hasSubtype ? header.readUInt32LE(0x1c8) : null;
|
||||
assert.deepEqual(
|
||||
inspected.cache_subtype,
|
||||
subtype === null
|
||||
? null
|
||||
: {
|
||||
id: subtype,
|
||||
name:
|
||||
subtype === 0
|
||||
? "development"
|
||||
: subtype === 1
|
||||
? "production"
|
||||
: null,
|
||||
},
|
||||
"multi-cache subtype differs from the producer header",
|
||||
);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await withArtifactMcp(null, async (client) => {
|
||||
const viaMcp = await artifactMcpResult(
|
||||
client,
|
||||
"inspect_dyld_shared_cache",
|
||||
{
|
||||
cache_path: cachePath,
|
||||
images: ["/usr/lib/libSystem.B.dylib"],
|
||||
},
|
||||
);
|
||||
assert.deepEqual(viaMcp, inspected);
|
||||
});
|
||||
|
||||
const { app } = await buildMacosBundleFixture(root);
|
||||
const trace = await artifactCli("trace-dylib-resolution", app, [
|
||||
"--shared-cache",
|
||||
cachePath,
|
||||
]);
|
||||
assert.equal(trace.shared_cache.uuid, inspected.uuid);
|
||||
const main = "Contents/MacOS/MacFixture";
|
||||
const cached = trace.edges.filter(
|
||||
({ root: edgeRoot, resolution }) =>
|
||||
edgeRoot === main && resolution.status === "shared-cache",
|
||||
);
|
||||
assert.ok(
|
||||
cached.some(
|
||||
({ install_name: name }) => name === "/usr/lib/libSystem.B.dylib",
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
trace.edges.every(({ install_name: name, resolution }) =>
|
||||
name.startsWith("/usr/lib/") || name.startsWith("/System/")
|
||||
? resolution.status === "shared-cache"
|
||||
: true,
|
||||
),
|
||||
"a system install path was not found in the host cache",
|
||||
);
|
||||
const { stderr } = await exec(join(app, main), [], {
|
||||
env: { PATH: "/usr/bin:/bin", DYLD_PRINT_LIBRARIES: "1" },
|
||||
});
|
||||
const loaded = new Set(
|
||||
stderr
|
||||
.split("\n")
|
||||
.map((line) => /^dyld\[\d+\]: <[0-9A-F-]+> (.+)$/u.exec(line)?.[1])
|
||||
.filter((path) => path !== undefined),
|
||||
);
|
||||
for (const { resolution } of cached)
|
||||
assert.ok(
|
||||
loaded.has(resolution.image),
|
||||
`${resolution.image} was not loaded by dyld`,
|
||||
);
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
ok: true,
|
||||
mocked: false,
|
||||
cli: true,
|
||||
stdio_mcp: true,
|
||||
cache: {
|
||||
architecture: inspected.architecture,
|
||||
os_version: inspected.os_version,
|
||||
images: inspected.images_total,
|
||||
type: inspected.cache_type,
|
||||
subtype: inspected.cache_subtype,
|
||||
},
|
||||
images_matching_dyld_info: compared,
|
||||
trace_shared_cache_edges: cached.length,
|
||||
dyld_loaded_every_cached_edge: true,
|
||||
host: await realpath(cachePath),
|
||||
})}\n`,
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
+7
-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: "4c5f67d076baf821b5cb6d6fb2cc0f6c274e96b627b85b892577760a6f71ab1a"
|
||||
---
|
||||
|
||||
# REA
|
||||
@@ -146,6 +144,13 @@ Start with the default result and use its inline Evidence and graph context.
|
||||
Do not repeat an identical tool call. Make a focused follow-up only when the
|
||||
returned result leaves a specific question unanswered.
|
||||
|
||||
If MCP reports `resource_constraint` with `details.resource: "transport"`, use
|
||||
its reported same-session Evidence reference with a focused application tool,
|
||||
or call `export_evidence_bundle` to write the complete session to a selected
|
||||
path. The analysis remains complete in the ledger; avoid repeating it merely
|
||||
to request the same oversized response. Complete CLI JSON output also streams
|
||||
without a single MCP frame.
|
||||
|
||||
Every conclusion must distinguish observations, inferences, and unknowns. Cite
|
||||
Evidence IDs, preserve limitations and incomplete coverage, and never imply
|
||||
that static analysis observed execution. Runtime requests execute the declared
|
||||
+8
-1
@@ -2,10 +2,17 @@
|
||||
|
||||
Use `analyze_javascript_application` directly on the operator-supplied ASAR or
|
||||
extracted tree. The complete result, graph, and Evidence context are returned
|
||||
inline. Source-map contents are part of the static analysis. Start from its
|
||||
inline when they fit the MCP frame. Source-map contents are part of the static analysis. Start from its
|
||||
findings, coverage, unknowns, and graph context, then make a focused follow-up
|
||||
only when a specific question remains unanswered.
|
||||
|
||||
An oversized response reports `resource_constraint` and
|
||||
`details.resource: "transport"`. Reuse
|
||||
`details.reported_limits.evidence_reference` with `trace_application_feature`
|
||||
for the module or feature under investigation. `export_evidence_bundle` writes
|
||||
the complete canonical session to a caller-selected file. Both workflows keep
|
||||
the original Evidence and coverage; a broad follow-up can also exceed framing.
|
||||
|
||||
BrowserWindow preferences, preload and contextBridge surfaces, IPC
|
||||
registrations, utility processes, and native binding requests are static syntax
|
||||
observations. Only a unique exact literal IPC channel match supports an inferred
|
||||
+4
-2
@@ -103,8 +103,10 @@ After `open_binary` on a `.app` or Mach-O, use `trace_dylib_resolution` to see
|
||||
which file each `@rpath`, `@loader_path` and `@executable_path` load reaches for
|
||||
every executable in the bundle. It also lists missing or weak loads and earlier
|
||||
`@rpath` candidates that are absent. Narrow large bundles with `roots` or
|
||||
`architecture`. System paths stay undetermined because the dyld shared cache
|
||||
provides them.
|
||||
`architecture`. Pass `shared_cache` (the host cache or one from an IPSW) to
|
||||
resolve system paths to `shared-cache`; without it they stay undetermined.
|
||||
Use `inspect_dyld_shared_cache` with that cache path to list its images and read
|
||||
a cached library's dependencies without an open target.
|
||||
|
||||
Use `extract_artifact` when materialized files are needed. It takes no arguments
|
||||
and materializes all regular files into a fresh temporary directory chosen by
|
||||
@@ -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 (
|
||||
|
||||
@@ -30,11 +30,7 @@ import {
|
||||
snapshotEvidenceForQuery,
|
||||
snapshotMatchesTarget,
|
||||
} from "../domain/analysisSnapshot.js";
|
||||
import {
|
||||
analysisProfileSchema,
|
||||
committedProviderSchema,
|
||||
type AnalysisProfileCommitment,
|
||||
} from "../domain/analysisProfile.js";
|
||||
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type { AnalysisSnapshot } from "../domain/analysisSnapshot.js";
|
||||
import type {
|
||||
@@ -45,6 +41,10 @@ import {
|
||||
REA_WORKFLOW_PROVIDER,
|
||||
workflowAnalysisProfile,
|
||||
} from "./InvestigationProviders.js";
|
||||
import {
|
||||
createWorkflowEvidence,
|
||||
workflowSnapshotRecord,
|
||||
} from "./WorkflowEvidence.js";
|
||||
import type { AnalysisProviderSelector } from "../contracts/providerSelection.js";
|
||||
import { artifactInspectionResultSchema } from "../domain/artifactInspection.js";
|
||||
|
||||
@@ -268,35 +268,13 @@ const runAnalysis = async (
|
||||
tool !== "trace_native_ui_action" &&
|
||||
snapshotPath !== undefined &&
|
||||
evidence !== undefined &&
|
||||
"analysis_profile" in evidence &&
|
||||
session.allowsSnapshotReplay(tool)
|
||||
) {
|
||||
const recorded = session.recordWorkflowSnapshot({
|
||||
operation: tool,
|
||||
parameters: arguments_,
|
||||
execution: {
|
||||
result: evidence.normalized_result,
|
||||
rawResult: evidence.raw_result,
|
||||
provider: committedProviderSchema.parse(evidence.provider),
|
||||
analysisProfile: analysisProfileSchema.parse(
|
||||
evidence.analysis_profile,
|
||||
),
|
||||
limitations: evidence.limitations,
|
||||
locations: evidence.locations,
|
||||
subject:
|
||||
evidence.subject === null
|
||||
? null
|
||||
: {
|
||||
path: evidence.subject.local_path,
|
||||
sha256: evidence.subject.digest.sha256,
|
||||
format: evidence.subject.format,
|
||||
...(evidence.subject.architecture === null
|
||||
? {}
|
||||
: { architecture: evidence.subject.architecture }),
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!recorded.ok) return cliError(recorded.error);
|
||||
const workflowRecord = workflowSnapshotRecord(evidence, tool);
|
||||
if (workflowRecord !== undefined) {
|
||||
const recorded = session.recordWorkflowSnapshot(workflowRecord);
|
||||
if (!recorded.ok) return cliError(recorded.error);
|
||||
}
|
||||
}
|
||||
if (
|
||||
tool !== "trace_native_ui_action" &&
|
||||
@@ -356,15 +334,12 @@ const executeAnalysisTool = async (input: {
|
||||
signal,
|
||||
);
|
||||
if (!result.ok) return { output: cliError(result.error) };
|
||||
const evidence = createEvidence(input.openedTarget, REA_WORKFLOW_PROVIDER, {
|
||||
const evidence = createWorkflowEvidence({
|
||||
target: input.openedTarget,
|
||||
operation: tool,
|
||||
parameters: input.arguments,
|
||||
result: result.value,
|
||||
...(evidenceProfile === undefined
|
||||
? {}
|
||||
: { analysisProfile: evidenceProfile }),
|
||||
confidence: "derived",
|
||||
limitations: ["Derived by an REA composed workflow."],
|
||||
upstreamProfile: evidenceProfile,
|
||||
});
|
||||
return { output: evidence, evidence };
|
||||
}
|
||||
@@ -493,8 +468,7 @@ const analysisProfileForEvidence = (
|
||||
| DirectAnalysisTool,
|
||||
): AnalysisProfileCommitment | undefined => {
|
||||
if (!isWorkflowEvidenceTool(tool)) return session.analysisProfile(tool);
|
||||
const upstream = session.analysisProfile();
|
||||
return upstream === undefined ? undefined : workflowAnalysisProfile(upstream);
|
||||
return session.analysisProfile();
|
||||
};
|
||||
|
||||
const isWorkflowEvidenceTool = (
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
import {
|
||||
describeEvidenceBundleFailure,
|
||||
describeValidationFailure,
|
||||
parseEvidenceBundle,
|
||||
serializeEvidenceBundle,
|
||||
type EvidenceBundle,
|
||||
} from "../domain/evidenceBundle.js";
|
||||
import {
|
||||
@@ -9,7 +10,11 @@ import {
|
||||
} from "../domain/evidenceErrors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { parseProcessCapture } from "../domain/process/processCapture.js";
|
||||
import { readJsonFile, writeTextFile } from "./JsonFiles.js";
|
||||
import {
|
||||
bufferedJsonParts,
|
||||
canonicalJsonParts,
|
||||
} from "../domain/jsonSerialization.js";
|
||||
import { readJsonFile, writeTextParts } from "./JsonFiles.js";
|
||||
|
||||
type EvidenceReadFailure = EvidenceFileError | EvidenceIntegrityError;
|
||||
type EvidenceWriteFailure = EvidenceFileError | EvidenceIntegrityError;
|
||||
@@ -20,20 +25,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. */
|
||||
@@ -47,9 +63,9 @@ export const writeEvidenceBundle = async (
|
||||
EvidenceWriteFailure
|
||||
>
|
||||
> => {
|
||||
let encoded: string;
|
||||
let checked: EvidenceBundle;
|
||||
try {
|
||||
encoded = serializeEvidenceBundle(bundle);
|
||||
checked = parseEvidenceBundle(bundle);
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new EvidenceIntegrityError("Evidence bundle validation failed", {
|
||||
@@ -57,5 +73,9 @@ export const writeEvidenceBundle = async (
|
||||
}),
|
||||
);
|
||||
}
|
||||
return writeTextFile(encoded, path, overwrite);
|
||||
return writeTextParts(
|
||||
bufferedJsonParts(canonicalJsonParts(checked)),
|
||||
path,
|
||||
overwrite,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { link, lstat, mkdtemp, readFile, realpath, rm } from "node:fs/promises";
|
||||
import {
|
||||
link,
|
||||
lstat,
|
||||
mkdtemp,
|
||||
open,
|
||||
readFile,
|
||||
realpath,
|
||||
rename,
|
||||
rm,
|
||||
} from "node:fs/promises";
|
||||
import { basename, dirname, resolve } from "node:path";
|
||||
|
||||
import writeFileAtomic from "write-file-atomic";
|
||||
|
||||
import { EvidenceFileError } from "../domain/evidenceErrors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
@@ -10,10 +17,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 +34,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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -38,10 +59,18 @@ export const writeTextFile = async (
|
||||
overwrite: boolean,
|
||||
): Promise<
|
||||
Result<{ readonly path: string; readonly bytes: number }, EvidenceFileError>
|
||||
> => writeTextParts([encoded], path, overwrite);
|
||||
|
||||
/** Publish complete streamed text atomically, keeping only one encoded part in memory. */
|
||||
export const writeTextParts = async (
|
||||
parts: Iterable<string>,
|
||||
path: string,
|
||||
overwrite: boolean,
|
||||
): Promise<
|
||||
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,41 +78,65 @@ 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, {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
fsync: true,
|
||||
});
|
||||
else await publishNewFile(destination, encoded);
|
||||
const bytes = await publishFile(destination, parts, overwrite);
|
||||
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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const publishNewFile = async (
|
||||
/** 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 publishFile = async (
|
||||
destination: string,
|
||||
encoded: string,
|
||||
): Promise<void> => {
|
||||
parts: Iterable<string>,
|
||||
overwrite: boolean,
|
||||
): Promise<number> => {
|
||||
const stagingDirectory = await mkdtemp(
|
||||
resolve(dirname(destination), ".rea-write-"),
|
||||
);
|
||||
try {
|
||||
const staged = resolve(stagingDirectory, "content");
|
||||
await writeFileAtomic(staged, encoded, {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
fsync: true,
|
||||
});
|
||||
const file = await open(staged, "wx", 0o600);
|
||||
let bytes = 0;
|
||||
try {
|
||||
for (const part of parts) {
|
||||
await file.writeFile(part, { encoding: "utf8" });
|
||||
bytes += Buffer.byteLength(part, "utf8");
|
||||
}
|
||||
await file.sync();
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
if (overwrite) await rename(staged, destination);
|
||||
// A hard link publishes complete bytes atomically and cannot replace an existing name.
|
||||
await link(staged, destination);
|
||||
else await link(staged, destination);
|
||||
return bytes;
|
||||
} finally {
|
||||
await rm(stagingDirectory, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
REA_WORKFLOW_PROVIDER,
|
||||
workflowAnalysisProfile,
|
||||
} from "./InvestigationProviders.js";
|
||||
import type { AnalysisOperation } from "./AnalysisProvider.js";
|
||||
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
|
||||
import type { WorkflowSnapshotRecordInput } from "./binary/BinarySessionRecords.js";
|
||||
import {
|
||||
analysisProfileSchema,
|
||||
committedProviderSchema,
|
||||
} from "../domain/analysisProfile.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import { createEvidence, type Evidence } from "../domain/evidence.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
|
||||
/** Build the shared Evidence record for a successful composed binary workflow. */
|
||||
export const createWorkflowEvidence = (input: {
|
||||
readonly target: BinaryTarget | undefined;
|
||||
readonly operation: AnalysisOperation;
|
||||
readonly parameters: Readonly<Record<string, JsonValue>>;
|
||||
readonly result: JsonValue;
|
||||
readonly upstreamProfile: AnalysisProfileCommitment | undefined;
|
||||
}): Evidence =>
|
||||
createEvidence(input.target, REA_WORKFLOW_PROVIDER, {
|
||||
operation: input.operation,
|
||||
parameters: input.parameters,
|
||||
result: input.result,
|
||||
...(input.upstreamProfile === undefined
|
||||
? {}
|
||||
: { analysisProfile: workflowAnalysisProfile(input.upstreamProfile) }),
|
||||
confidence: "derived",
|
||||
limitations: ["Derived by an REA composed workflow."],
|
||||
});
|
||||
|
||||
/** Convert workflow Evidence into its exact replay binding, when it has a profile. */
|
||||
export const workflowSnapshotRecord = (
|
||||
evidence: Evidence,
|
||||
operation: AnalysisOperation,
|
||||
): WorkflowSnapshotRecordInput | undefined => {
|
||||
if (!("analysis_profile" in evidence)) return undefined;
|
||||
return {
|
||||
operation,
|
||||
parameters: evidence.parameters,
|
||||
execution: {
|
||||
result: evidence.normalized_result,
|
||||
rawResult: evidence.raw_result,
|
||||
provider: committedProviderSchema.parse(evidence.provider),
|
||||
analysisProfile: analysisProfileSchema.parse(evidence.analysis_profile),
|
||||
limitations: evidence.limitations,
|
||||
locations: evidence.locations,
|
||||
subject:
|
||||
evidence.subject === null
|
||||
? null
|
||||
: {
|
||||
path: evidence.subject.local_path,
|
||||
sha256: evidence.subject.digest.sha256,
|
||||
format: evidence.subject.format,
|
||||
...(evidence.subject.architecture === null
|
||||
? {}
|
||||
: { architecture: evidence.subject.architecture }),
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,195 @@
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
||||
import { DyldSharedCache } from "../../artifacts/apple/DyldSharedCacheReader.js";
|
||||
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { ArtifactOperationError } from "../../domain/artifactOperationError.js";
|
||||
import {
|
||||
DYLD_SHARED_CACHE_LIMITATIONS,
|
||||
dyldSharedCacheResultSchema,
|
||||
inspectDyldSharedCacheInputSchema,
|
||||
type DyldSharedCacheResult,
|
||||
type InspectedCacheImage,
|
||||
} from "../../domain/apple/dyldSharedCache.js";
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import { jsonValueSchema } from "../../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { APPLE_APPLICATION_PROVIDER } from "../InvestigationProviders.js";
|
||||
|
||||
const OPERATION = "inspect_dyld_shared_cache" as const;
|
||||
|
||||
/** Read one requested image through the cache, keeping absence explicit. */
|
||||
const inspectImage = async (
|
||||
cache: DyldSharedCache,
|
||||
path: string,
|
||||
): Promise<InspectedCacheImage> => {
|
||||
const image = cache.find(path);
|
||||
const base = { path, reason: null, slices: [] };
|
||||
if (image === undefined)
|
||||
return { ...base, status: "absent", address: null, file: null };
|
||||
const address = `0x${image.address.toString(16)}`;
|
||||
const read = await cache.imageFacts(path);
|
||||
if (read === undefined)
|
||||
return {
|
||||
...base,
|
||||
status: "unmapped",
|
||||
address,
|
||||
file: null,
|
||||
reason: "The image address lies in no readable cache mapping.",
|
||||
};
|
||||
const { facts, file } = read;
|
||||
if (facts.status === "parsed")
|
||||
return {
|
||||
...base,
|
||||
status: "parsed",
|
||||
address,
|
||||
file,
|
||||
slices: [...facts.slices],
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
status: facts.status === "not-mach-o" ? "malformed" : facts.status,
|
||||
address,
|
||||
file,
|
||||
reason:
|
||||
facts.status === "not-mach-o"
|
||||
? "The cached image is not a Mach-O image."
|
||||
: facts.reason,
|
||||
};
|
||||
};
|
||||
|
||||
const failure = (cause: unknown): AnalysisError => {
|
||||
if (cause instanceof ArtifactReaderFailure)
|
||||
return new ArtifactOperationError(
|
||||
OPERATION,
|
||||
cause.reason,
|
||||
undefined,
|
||||
cause.message,
|
||||
);
|
||||
if (cause instanceof Error && "code" in cause) {
|
||||
if (cause.code === "EISDIR")
|
||||
return new ArtifactOperationError(
|
||||
OPERATION,
|
||||
"path",
|
||||
undefined,
|
||||
cause.message,
|
||||
);
|
||||
if (cause.code === "ENOENT" || cause.code === "ENOTDIR")
|
||||
return new ArtifactOperationError(
|
||||
OPERATION,
|
||||
"path",
|
||||
undefined,
|
||||
"The cache file does not exist.",
|
||||
);
|
||||
if (cause.code === "EACCES" || cause.code === "EPERM")
|
||||
return new ArtifactOperationError(
|
||||
OPERATION,
|
||||
"unavailable",
|
||||
undefined,
|
||||
"The cache file is not readable.",
|
||||
);
|
||||
}
|
||||
if (cause instanceof Error && cause.name === "AbortError")
|
||||
return new ArtifactOperationError(OPERATION, "cancelled");
|
||||
return new ArtifactOperationError(
|
||||
OPERATION,
|
||||
"io",
|
||||
undefined,
|
||||
cause instanceof Error ? cause.message : String(cause),
|
||||
);
|
||||
};
|
||||
|
||||
/** Inspect a dyld shared cache file set selected by path. */
|
||||
export const inspectDyldSharedCache = async (
|
||||
rawInput: unknown,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<DyldSharedCacheResult, AnalysisError>> => {
|
||||
const parsed = inspectDyldSharedCacheInputSchema.safeParse(rawInput);
|
||||
if (!parsed.success)
|
||||
return err(new AnalysisInputError(OPERATION, { cause: parsed.error }));
|
||||
// Keep the caller-selected spelling for result and Evidence metadata; only
|
||||
// filesystem access uses the resolved path.
|
||||
const selectedPath = parsed.data.cache_path;
|
||||
const cachePath = resolve(selectedPath);
|
||||
let cache: DyldSharedCache | undefined;
|
||||
try {
|
||||
cache = await DyldSharedCache.open(cachePath, signal);
|
||||
const inspected: InspectedCacheImage[] = [];
|
||||
for (const path of parsed.data.images ?? []) {
|
||||
signal?.throwIfAborted();
|
||||
inspected.push(await inspectImage(cache, path));
|
||||
}
|
||||
const unreadable = cache.header.subcaches
|
||||
.filter(({ status }) => status !== "present")
|
||||
.map(({ suffix }) => suffix);
|
||||
// Hash through the open handles the header was parsed from, so a path
|
||||
// replacement mid-inspection cannot attribute new bytes to old facts.
|
||||
const mainSha = await cache.mainSha256(signal);
|
||||
const subcacheSha = await cache.subcacheSha256(signal);
|
||||
return ok(
|
||||
dyldSharedCacheResultSchema.parse({
|
||||
cache_path: selectedPath,
|
||||
// The header and image list were read through this same handle.
|
||||
main_file_sha256: mainSha,
|
||||
...cache.header,
|
||||
subcache_sha256: subcacheSha,
|
||||
images_total: cache.images.length,
|
||||
images: cache.images.map(({ path, address }) => ({
|
||||
path,
|
||||
address: `0x${address.toString(16)}`,
|
||||
})),
|
||||
inspected_images: inspected,
|
||||
coverage: {
|
||||
status: unreadable.length === 0 ? "complete" : "partial",
|
||||
unreadable_subcaches: unreadable,
|
||||
},
|
||||
limitations: [
|
||||
...DYLD_SHARED_CACHE_LIMITATIONS,
|
||||
...(unreadable.length === 0
|
||||
? []
|
||||
: [
|
||||
`Subcaches ${unreadable.join(", ")} are missing or do not match the main cache UUID; images mapped there are unmapped.`,
|
||||
]),
|
||||
],
|
||||
}),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return err(failure(cause));
|
||||
} finally {
|
||||
await cache?.close();
|
||||
}
|
||||
};
|
||||
|
||||
/** Inspect a dyld shared cache and wrap the observation as Evidence. */
|
||||
export const inspectDyldSharedCacheEvidence = async (
|
||||
rawInput: unknown,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const inspected = await inspectDyldSharedCache(rawInput, signal);
|
||||
if (!inspected.ok) return inspected;
|
||||
const result = inspected.value;
|
||||
return ok(
|
||||
createEvidence(
|
||||
{
|
||||
path: result.cache_path,
|
||||
sha256: result.main_file_sha256,
|
||||
format: "file",
|
||||
},
|
||||
APPLE_APPLICATION_PROVIDER,
|
||||
{
|
||||
operation: OPERATION,
|
||||
parameters: {
|
||||
cache_path: result.cache_path,
|
||||
images: result.inspected_images.map(({ path }) => path),
|
||||
},
|
||||
result: jsonValueSchema.parse(result),
|
||||
rawResult: null,
|
||||
confidence: "observed",
|
||||
authority: "shipped-artifact",
|
||||
environment: null,
|
||||
limitations: result.limitations,
|
||||
},
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -7,6 +7,8 @@ import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js"
|
||||
import type { AnalysisProviderSelector } from "../../contracts/providerSelection.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type { Result } from "../../domain/result.js";
|
||||
import type { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
|
||||
import type { WorkflowSnapshotRecordInput } from "./BinarySessionRecords.js";
|
||||
|
||||
import type {
|
||||
AnalysisOperation,
|
||||
@@ -43,6 +45,10 @@ export interface BinarySessionPort
|
||||
analysisProfile(
|
||||
operation?: AnalysisOperation,
|
||||
): AnalysisProfileCommitment | undefined;
|
||||
allowsSnapshotReplay(operation: AnalysisOperation): boolean;
|
||||
recordWorkflowSnapshot(
|
||||
input: WorkflowSnapshotRecordInput,
|
||||
): Result<null, EvidenceIntegrityError>;
|
||||
openCompatibility(): Readonly<Record<string, JsonValue>>;
|
||||
onAvailabilityChanged?(listener: () => void | Promise<void>): () => void;
|
||||
onAnalysisSnapshotChanged?(listener: () => void | Promise<void>): () => void;
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AnalysisSnapshot } from "../../domain/analysisSnapshot.js";
|
||||
import type { BinaryTarget } from "../../domain/binaryTarget.js";
|
||||
import type { Evidence } from "../../domain/evidence.js";
|
||||
import type { EvidenceBundle } from "../../domain/evidenceBundle.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import { evidenceBundleForTarget } from "../../domain/evidenceBundle.js";
|
||||
import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
|
||||
import { type AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
@@ -26,6 +27,15 @@ export interface ActiveAnalysisBinding {
|
||||
readonly profile: AnalysisProfileCommitment | null;
|
||||
}
|
||||
|
||||
/** Validated composed-workflow payload bound to the active target and profile. */
|
||||
export interface WorkflowSnapshotRecordInput {
|
||||
readonly operation: AnalysisOperation;
|
||||
readonly parameters: Readonly<Record<string, JsonValue>>;
|
||||
readonly execution: Parameters<
|
||||
AnalysisSnapshotCache["recordWorkflow"]
|
||||
>[0]["execution"];
|
||||
}
|
||||
|
||||
/** Binary snapshot owner and compatibility facade for composed investigation records. */
|
||||
export abstract class BinarySessionRecords {
|
||||
readonly #records: InvestigationRecords;
|
||||
@@ -59,10 +69,20 @@ export abstract class BinarySessionRecords {
|
||||
return this.#records.evidenceById(evidenceId);
|
||||
}
|
||||
|
||||
/** Borrow immutable investigation Evidence without copying a complete graph. */
|
||||
evidenceForAnalysis(evidenceId: string): Evidence | undefined {
|
||||
return this.#records.evidenceForAnalysis(evidenceId);
|
||||
}
|
||||
|
||||
exportEvidenceBundle(): EvidenceBundle {
|
||||
return this.#records.exportEvidenceBundle();
|
||||
}
|
||||
|
||||
/** Borrow a sealed bundle for complete serialization without cloning retained graphs. */
|
||||
evidenceBundleForSerialization(): EvidenceBundle {
|
||||
return this.#records.evidenceBundleForSerialization();
|
||||
}
|
||||
|
||||
importEvidenceBundle(
|
||||
bundle: unknown,
|
||||
): Result<number, EvidenceIntegrityError> {
|
||||
@@ -161,15 +181,9 @@ export abstract class BinarySessionRecords {
|
||||
}
|
||||
|
||||
/** Retain one derived workflow result alongside its provider cache entries. */
|
||||
recordWorkflowSnapshot(input: {
|
||||
readonly operation: string;
|
||||
readonly parameters: Readonly<
|
||||
Record<string, import("../../domain/jsonValue.js").JsonValue>
|
||||
>;
|
||||
readonly execution: Parameters<
|
||||
AnalysisSnapshotCache["recordWorkflow"]
|
||||
>[0]["execution"];
|
||||
}): Result<null, EvidenceIntegrityError> {
|
||||
recordWorkflowSnapshot(
|
||||
input: WorkflowSnapshotRecordInput,
|
||||
): Result<null, EvidenceIntegrityError> {
|
||||
const active = this.activeAnalysisBinding();
|
||||
if (active === undefined || active.profile === null)
|
||||
return err(
|
||||
|
||||
@@ -116,6 +116,36 @@ describe("evidence ledger recording", () => {
|
||||
ledger.clear();
|
||||
expect(ledger.export().records).toEqual([]);
|
||||
});
|
||||
|
||||
it("protects borrowed snapshots while preserving detached mutable public reads", () => {
|
||||
const evidence = createEvidence(TARGET, PROVIDER, {
|
||||
operation: "health",
|
||||
parameters: {},
|
||||
result: { nested: { value: "original" } },
|
||||
});
|
||||
const ledger = new EvidenceLedger();
|
||||
expect(ledger.record(evidence).ok).toBe(true);
|
||||
const borrowed = ledger.forAnalysis(evidence.evidence_id);
|
||||
if (borrowed === undefined) throw new Error("Missing recorded Evidence");
|
||||
expect(Object.isFrozen(borrowed)).toBe(true);
|
||||
expect(Object.isFrozen(borrowed.normalized_result)).toBe(true);
|
||||
expect(Reflect.set(borrowed, "operation", "forged")).toBe(false);
|
||||
expect(ledger.forAnalysis(evidence.evidence_id)).toBe(borrowed);
|
||||
expect(ledger.record(borrowed)).toEqual({ ok: true, value: "duplicate" });
|
||||
const detached = ledger.get(evidence.evidence_id);
|
||||
if (detached === undefined) throw new Error("Missing detached Evidence");
|
||||
expect(Reflect.set(detached, "operation", "forged")).toBe(true);
|
||||
expect(ledger.record(detached).ok).toBe(false);
|
||||
expect(ledger.forAnalysis(evidence.evidence_id)).toBe(borrowed);
|
||||
const snapshot = ledger.forSerialization();
|
||||
expect(snapshot).toEqual(ledger.export());
|
||||
expect(snapshot.records[0]).toBe(borrowed);
|
||||
expect(Object.isFrozen(snapshot.records)).toBe(true);
|
||||
ledger.clear();
|
||||
expect(ledger.forAnalysis(evidence.evidence_id)).toBeUndefined();
|
||||
expect(ledger.forSerialization().records).toEqual([]);
|
||||
expect(snapshot.records).toEqual([evidence]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("evidence ledger metadata imports", () => {
|
||||
|
||||
@@ -2,13 +2,19 @@ import canonicalize from "canonicalize";
|
||||
|
||||
import {
|
||||
createEvidenceBundle,
|
||||
createImmutableEvidenceBundle,
|
||||
describeEvidenceBundleFailure,
|
||||
parseEvidenceBundle,
|
||||
validateResidualUnknownAddition,
|
||||
type EvidenceBundle,
|
||||
} from "../../domain/evidenceBundle.js";
|
||||
import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
|
||||
import { UnknownRegistryError } from "../../domain/unknownRegistryError.js";
|
||||
import { parseEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import {
|
||||
immutableEvidence,
|
||||
parseEvidence,
|
||||
type Evidence,
|
||||
} from "../../domain/evidence.js";
|
||||
import {
|
||||
createResidualUnknown,
|
||||
updateResidualUnknown,
|
||||
@@ -41,7 +47,7 @@ export class EvidenceLedger {
|
||||
record(input: Evidence): RecordResult {
|
||||
let evidence: Evidence;
|
||||
try {
|
||||
evidence = parseEvidence(input);
|
||||
evidence = immutableEvidence(input);
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new EvidenceIntegrityError("Evidence record validation failed", {
|
||||
@@ -51,6 +57,10 @@ export class EvidenceLedger {
|
||||
}
|
||||
const existing = this.#records.get(evidence.evidence_id);
|
||||
if (existing !== undefined) {
|
||||
// A borrowed record is already authenticated and deeply immutable. Its
|
||||
// identity cannot change between reads, so comparing document-sized
|
||||
// canonical strings again would add no integrity check.
|
||||
if (existing === evidence) return ok("duplicate");
|
||||
return recordsAgree(existing, evidence)
|
||||
? ok("duplicate")
|
||||
: err(new EvidenceIntegrityError("Conflicting evidence record"));
|
||||
@@ -68,6 +78,7 @@ export class EvidenceLedger {
|
||||
return err(
|
||||
new EvidenceIntegrityError("Evidence bundle validation failed", {
|
||||
cause,
|
||||
userMessage: describeEvidenceBundleFailure(input, cause),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -95,8 +106,9 @@ export class EvidenceLedger {
|
||||
additions.set(evidence.evidence_id, evidence);
|
||||
}
|
||||
for (const [id, evidence] of replacements)
|
||||
this.#records.set(id, evidence);
|
||||
for (const [id, evidence] of additions) this.#records.set(id, evidence);
|
||||
this.#records.set(id, immutableEvidence(evidence));
|
||||
for (const [id, evidence] of additions)
|
||||
this.#records.set(id, immutableEvidence(evidence));
|
||||
return ok({
|
||||
recordsAdded: additions.size,
|
||||
unknownsAdded: 0,
|
||||
@@ -163,6 +175,14 @@ export class EvidenceLedger {
|
||||
);
|
||||
}
|
||||
|
||||
/** Borrow a sealed canonical bundle for serialization without copying its records. */
|
||||
forSerialization(): EvidenceBundle {
|
||||
return createImmutableEvidenceBundle(
|
||||
[...this.#records.values()],
|
||||
[...this.#unknownRevisions.values()],
|
||||
);
|
||||
}
|
||||
|
||||
/** Check whether one immutable Evidence ID is present in this ledger. */
|
||||
has(evidenceId: string): boolean {
|
||||
return this.#records.has(evidenceId);
|
||||
@@ -174,6 +194,11 @@ export class EvidenceLedger {
|
||||
return evidence === undefined ? undefined : structuredClone(evidence);
|
||||
}
|
||||
|
||||
/** Borrow authenticated immutable Evidence for read-only analysis without a full clone. */
|
||||
forAnalysis(evidenceId: string): Evidence | undefined {
|
||||
return this.#records.get(evidenceId);
|
||||
}
|
||||
|
||||
/** Create one approved unknown and atomically append its mutation evidence. */
|
||||
recordUnknown(
|
||||
input: RecordUnknownInput,
|
||||
@@ -378,7 +403,8 @@ export class EvidenceLedger {
|
||||
} catch (cause: unknown) {
|
||||
return err(new UnknownRegistryError("integrity", { cause }));
|
||||
}
|
||||
for (const [id, evidence] of additions) this.#records.set(id, evidence);
|
||||
for (const [id, evidence] of additions)
|
||||
this.#records.set(id, immutableEvidence(evidence));
|
||||
if (unknown !== undefined && unknownKey !== undefined) {
|
||||
this.#unknownRevisions.set(unknownKey, unknown);
|
||||
this.#unknownHeads.set(unknown.unknown_id, unknown);
|
||||
@@ -405,7 +431,8 @@ export class EvidenceLedger {
|
||||
unknowns: ReadonlyMap<string, ResidualUnknown>,
|
||||
): void {
|
||||
this.#records.clear();
|
||||
for (const [id, evidence] of records) this.#records.set(id, evidence);
|
||||
for (const [id, evidence] of records)
|
||||
this.#records.set(id, immutableEvidence(evidence));
|
||||
this.#unknownRevisions.clear();
|
||||
this.#unknownHeads.clear();
|
||||
for (const [key, unknown] of unknowns) {
|
||||
|
||||
@@ -15,6 +15,10 @@ import type { Result } from "../../domain/result.js";
|
||||
export interface EvidenceReader {
|
||||
hasEvidence(evidenceId: string): boolean;
|
||||
evidenceById(evidenceId: string): Evidence | undefined;
|
||||
/** Optional immutable analysis read; older adapters can keep detached reads. */
|
||||
evidenceForAnalysis?(evidenceId: string): Evidence | undefined;
|
||||
/** Optional immutable bundle snapshot for complete serialization without graph copies. */
|
||||
evidenceBundleForSerialization?(): EvidenceBundle;
|
||||
exportEvidenceBundle(): EvidenceBundle;
|
||||
}
|
||||
|
||||
|
||||
@@ -51,11 +51,21 @@ export class InvestigationRecords
|
||||
return this.#evidence.get(evidenceId);
|
||||
}
|
||||
|
||||
/** Borrow an immutable authenticated record for a read-only application workflow. */
|
||||
evidenceForAnalysis(evidenceId: string): Evidence | undefined {
|
||||
return this.#evidence.forAnalysis(evidenceId);
|
||||
}
|
||||
|
||||
/** Export a detached deterministic bundle of Evidence and Unknown revisions. */
|
||||
exportEvidenceBundle(): EvidenceBundle {
|
||||
return this.#evidence.export();
|
||||
}
|
||||
|
||||
/** Borrow a sealed bundle snapshot for complete JSON serialization. */
|
||||
evidenceBundleForSerialization(): EvidenceBundle {
|
||||
return this.#evidence.forSerialization();
|
||||
}
|
||||
|
||||
/** Atomically merge a bundle and report changes for the owner's post-commit observers. */
|
||||
mergeEvidenceBundle(
|
||||
bundle: unknown,
|
||||
|
||||
@@ -8,6 +8,18 @@ import type {
|
||||
JavaScriptApplicationAnalysisResult,
|
||||
} from "../../domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { JAVASCRIPT_APPLICATION_PROVIDER } from "../InvestigationProviders.js";
|
||||
import { freezeJsonSnapshot } from "../../domain/immutableJson.js";
|
||||
import { rememberOwnedApplicationGraphEvidence } from "./JavaScriptApplicationEvidenceGraph.js";
|
||||
|
||||
/** Transfer an already validated, application-owned result into immutable analysis Evidence. */
|
||||
export const createOwnedJavaScriptApplicationEvidence = (
|
||||
input: AnalyzeJavaScriptApplicationInput,
|
||||
result: JavaScriptApplicationAnalysisResult,
|
||||
): Evidence =>
|
||||
rememberOwnedApplicationGraphEvidence(
|
||||
createJavaScriptApplicationEvidence(input, freezeJsonSnapshot(result)),
|
||||
result,
|
||||
);
|
||||
|
||||
/** Create Evidence for one deterministic local JavaScript application graph. */
|
||||
export const createJavaScriptApplicationEvidence = (
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../../contracts/javascript/javascriptRuntimeReconciliationExample.js";
|
||||
import { parseEvidence } from "../../domain/evidence.js";
|
||||
import {
|
||||
analyzeJavaScriptApplicationInputSchema,
|
||||
javascriptApplicationAnalysisResultSchema,
|
||||
} from "../../domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import {
|
||||
createJavaScriptApplicationEvidence,
|
||||
createOwnedJavaScriptApplicationEvidence,
|
||||
} from "./JavaScriptApplicationEvidence.js";
|
||||
import {
|
||||
parseApplicationGraphEvidence,
|
||||
rememberOwnedApplicationGraphEvidence,
|
||||
} from "./JavaScriptApplicationEvidenceGraph.js";
|
||||
|
||||
describe("owned application graph Evidence", () => {
|
||||
it("reuses the exact authenticated graph and preserves ordinary wire identity", () => {
|
||||
const result = javascriptApplicationAnalysisResultSchema.parse(
|
||||
JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE.normalized_result,
|
||||
);
|
||||
const input = analyzeJavaScriptApplicationInputSchema.parse({
|
||||
input_path: result.input_path,
|
||||
...JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE.parameters,
|
||||
});
|
||||
const ordinary = createJavaScriptApplicationEvidence(input, result);
|
||||
const owned = createOwnedJavaScriptApplicationEvidence(input, result);
|
||||
expect(owned.evidence_id).toBe(ordinary.evidence_id);
|
||||
expect(JSON.stringify(owned)).toBe(JSON.stringify(ordinary));
|
||||
const parsed = parseApplicationGraphEvidence(owned);
|
||||
expect(parsed.evidence).toBe(owned);
|
||||
expect(parsed.graph).toBe(result.graph);
|
||||
expect(parsed.semanticGraph).toBe(result.semantic_graph);
|
||||
expect(Object.isFrozen(parsed.graph.nodes)).toBe(true);
|
||||
expect(parseApplicationGraphEvidence(owned)).toBe(parsed);
|
||||
const wire: unknown = JSON.parse(JSON.stringify(owned));
|
||||
expect(parseApplicationGraphEvidence(wire)).toEqual(parsed);
|
||||
expect(parseApplicationGraphEvidence(wire).graph).not.toBe(parsed.graph);
|
||||
expect(() =>
|
||||
rememberOwnedApplicationGraphEvidence(ordinary, result),
|
||||
).toThrow("exact validated immutable result");
|
||||
const tampered = { ...owned, normalized_result: {} };
|
||||
expect(() => parseEvidence(Object.freeze(tampered))).toThrow();
|
||||
expect(() => parseApplicationGraphEvidence(tampered)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,11 @@ import {
|
||||
JAVASCRIPT_RUNTIME_RECONCILIATION_PROVIDER,
|
||||
MANAGED_WORKFLOW_PROVIDER,
|
||||
} from "../InvestigationProviders.js";
|
||||
import { parseEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import {
|
||||
isImmutableEvidence,
|
||||
parseEvidence,
|
||||
type Evidence,
|
||||
} from "../../domain/evidence.js";
|
||||
import {
|
||||
analyzeJavaScriptApplicationInputSchema,
|
||||
javascriptApplicationAnalysisResultSchema,
|
||||
@@ -26,11 +30,39 @@ export interface ApplicationGraphEvidenceSource {
|
||||
readonly semanticGraph: JavaScriptSemanticGraph | null;
|
||||
}
|
||||
|
||||
const ownedApplicationSources = new WeakMap<
|
||||
Evidence,
|
||||
ApplicationGraphEvidenceSource
|
||||
>();
|
||||
|
||||
/** Retain the producer-validated meaning of its exact immutable result snapshot. */
|
||||
export const rememberOwnedApplicationGraphEvidence = (
|
||||
evidence: Evidence,
|
||||
result: JavaScriptApplicationAnalysisResult,
|
||||
): Evidence => {
|
||||
if (
|
||||
!isImmutableEvidence(evidence) ||
|
||||
evidence.normalized_result !== result ||
|
||||
evidence.operation !== "analyze_javascript_application" ||
|
||||
!providerMatches(evidence, JAVASCRIPT_APPLICATION_PROVIDER)
|
||||
)
|
||||
throw new TypeError(
|
||||
"Owned application Evidence must bind its exact validated immutable result",
|
||||
);
|
||||
ownedApplicationSources.set(
|
||||
evidence,
|
||||
Object.freeze(staticApplicationSourceForResult(evidence, result)),
|
||||
);
|
||||
return evidence;
|
||||
};
|
||||
|
||||
/** Parse and authenticate one REA-produced JavaScript Application Graph Evidence. */
|
||||
export const parseApplicationGraphEvidence = (
|
||||
input: unknown,
|
||||
): ApplicationGraphEvidenceSource => {
|
||||
const evidence = parseEvidence(input);
|
||||
const owned = ownedApplicationSources.get(evidence);
|
||||
if (owned !== undefined) return owned;
|
||||
if (
|
||||
evidence.operation === "analyze_javascript_application" &&
|
||||
evidence.predicate_type === "rea.javascript-application-analysis" &&
|
||||
@@ -56,6 +88,16 @@ export const parseApplicationGraphEvidence = (
|
||||
|
||||
const staticApplicationSource = (
|
||||
evidence: Evidence,
|
||||
): ApplicationGraphEvidenceSource => {
|
||||
const result = javascriptApplicationAnalysisResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
return staticApplicationSourceForResult(evidence, result);
|
||||
};
|
||||
|
||||
const staticApplicationSourceForResult = (
|
||||
evidence: Evidence,
|
||||
result: JavaScriptApplicationAnalysisResult,
|
||||
): ApplicationGraphEvidenceSource => {
|
||||
if (
|
||||
evidence.authority !== "shipped-artifact" ||
|
||||
@@ -64,9 +106,6 @@ const staticApplicationSource = (
|
||||
throw new TypeError(
|
||||
"JavaScript application Evidence authority or confidence is invalid",
|
||||
);
|
||||
const result = javascriptApplicationAnalysisResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
if (evidence.predicate_type !== "rea.javascript-application-analysis")
|
||||
throw new TypeError(
|
||||
"JavaScript application Evidence predicate does not match its result shape",
|
||||
|
||||
@@ -16,7 +16,7 @@ import { projectInputIssues } from "../../domain/inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
|
||||
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||
import { createJavaScriptApplicationEvidence } from "./JavaScriptApplicationEvidence.js";
|
||||
import { createOwnedJavaScriptApplicationEvidence } from "./JavaScriptApplicationEvidence.js";
|
||||
import { reconstructJavaScriptArtifact } from "./JavaScriptArtifactReconstruction.js";
|
||||
import { JAVASCRIPT_APPLICATION_PROVIDER } from "../InvestigationProviders.js";
|
||||
|
||||
@@ -77,7 +77,7 @@ export const analyzeJavaScriptApplicationValidated = async (
|
||||
total: 1,
|
||||
message: "Creating and hashing application analysis Evidence",
|
||||
});
|
||||
const evidence = createJavaScriptApplicationEvidence(input, result);
|
||||
const evidence = createOwnedJavaScriptApplicationEvidence(input, result);
|
||||
await options.progress?.report({
|
||||
phase: "analyze_javascript_application",
|
||||
completed: 1,
|
||||
|
||||
@@ -362,6 +362,35 @@ describe("contextual JavaScript package path resolution", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("generic package entry heuristics", () => {
|
||||
it.each([
|
||||
['{"main":"actual.cjs","module":"bundler.mjs"}', "actual.cjs"],
|
||||
['{"module":"bundler.mjs"}', "bundler.mjs"],
|
||||
])(
|
||||
"preserves generic entry heuristics without a module kind for %s",
|
||||
(metadata, entry) => {
|
||||
const files = fileMap([
|
||||
file("app/consumer.js", "root"),
|
||||
file("app/node_modules/fixture/package.json", "root", metadata),
|
||||
file("app/node_modules/fixture/actual.cjs", "root"),
|
||||
file("app/node_modules/fixture/bundler.mjs", "root"),
|
||||
file("app/node_modules/fixture/index.js", "root"),
|
||||
]);
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath: "fixture",
|
||||
sourcePath: "app/consumer.js",
|
||||
context: "module-specifier",
|
||||
files,
|
||||
}),
|
||||
).toMatchObject({
|
||||
resolution_status: "resolved",
|
||||
resolved_path: `app/node_modules/fixture/${entry}`,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("directory package entrypoint precedence", () => {
|
||||
it.each([
|
||||
["require", '{"main":"actual.cjs"}', "actual.cjs"],
|
||||
|
||||
@@ -461,10 +461,12 @@ const packageEntry = (
|
||||
? { ...exported, source: "exports" }
|
||||
: exported;
|
||||
}
|
||||
const preferred =
|
||||
moduleKind === "import"
|
||||
? [Reflect.get(value, "module"), Reflect.get(value, "main")]
|
||||
: [Reflect.get(value, "main"), Reflect.get(value, "module")];
|
||||
// Node imports and requires ignore the bundler-only module field. Keep
|
||||
// that fallback for callers without an explicit module-loading kind.
|
||||
const preferred = [
|
||||
Reflect.get(value, "main"),
|
||||
...(moduleKind === undefined ? [Reflect.get(value, "module")] : []),
|
||||
];
|
||||
const entry = preferred.find((candidate) => candidate !== undefined);
|
||||
if (entry === undefined) return { status: "missing" };
|
||||
const legacy = packagePathValue(entry);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
|
||||
@@ -113,6 +114,12 @@ const parseCaptureEvidence = (input: unknown) => {
|
||||
try {
|
||||
evidence = parseEvidence(input);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof z.ZodError)
|
||||
throw new AnalysisInputError(
|
||||
"compare_process_captures",
|
||||
{ cause },
|
||||
projectInputIssues(cause.issues, input),
|
||||
);
|
||||
throw invalidCaptureEvidence();
|
||||
}
|
||||
if (
|
||||
|
||||
@@ -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
|
||||
? [
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/** Synthetic dyld shared caches laid out field by field for tests. */
|
||||
|
||||
export interface CacheFixtureImage {
|
||||
readonly path: string;
|
||||
readonly bytes: Uint8Array;
|
||||
/** Place the image in the `.01` subcache instead of the main file. */
|
||||
readonly inSubcache?: boolean;
|
||||
}
|
||||
|
||||
export interface CacheFixture {
|
||||
readonly main: Uint8Array;
|
||||
readonly subcaches: readonly {
|
||||
readonly suffix: string;
|
||||
readonly bytes: Uint8Array;
|
||||
}[];
|
||||
}
|
||||
|
||||
const MAIN_BASE = 0x180000000n;
|
||||
const SUB_BASE = 0x190000000n;
|
||||
const MODERN_HEADER = 0x228;
|
||||
const LEGACY_HEADER = 0x98;
|
||||
|
||||
const uuid = (seed: number): Buffer =>
|
||||
Buffer.from(
|
||||
Array.from({ length: 16 }, (_, index) => (seed * 31 + index) & 0xff),
|
||||
);
|
||||
|
||||
const align = (value: number): number => Math.ceil(value / 0x1000) * 0x1000;
|
||||
|
||||
const writeHeader = (
|
||||
buffer: Buffer,
|
||||
options: {
|
||||
readonly headerSize: number;
|
||||
readonly uuidSeed: number;
|
||||
readonly architecture: string;
|
||||
},
|
||||
): void => {
|
||||
buffer.write(`dyld_v1 ${options.architecture}`.padEnd(15, " "), 0, "latin1");
|
||||
buffer.writeUInt32LE(options.headerSize, 0x10);
|
||||
buffer.writeUInt32LE(1, 0x14);
|
||||
uuid(options.uuidSeed).copy(buffer, 0x58);
|
||||
};
|
||||
|
||||
const writeMapping = (
|
||||
buffer: Buffer,
|
||||
at: number,
|
||||
address: bigint,
|
||||
size: number,
|
||||
): void => {
|
||||
buffer.writeBigUInt64LE(address, at);
|
||||
buffer.writeBigUInt64LE(BigInt(size), at + 8);
|
||||
buffer.writeBigUInt64LE(0n, at + 16);
|
||||
buffer.writeUInt32LE(5, at + 24);
|
||||
buffer.writeUInt32LE(5, at + 28);
|
||||
};
|
||||
|
||||
/** Build a main cache (modern or legacy header) plus an optional `.01` subcache. */
|
||||
export const dyldCacheFixture = (
|
||||
images: readonly CacheFixtureImage[],
|
||||
options: {
|
||||
readonly legacy?: boolean;
|
||||
readonly architecture?: string;
|
||||
readonly subcacheUuidSeed?: number;
|
||||
} = {},
|
||||
): CacheFixture => {
|
||||
const architecture = options.architecture ?? "arm64e";
|
||||
const legacy = options.legacy === true;
|
||||
const headerSize = legacy ? LEGACY_HEADER : MODERN_HEADER;
|
||||
const sub = images.filter(({ inSubcache }) => inSubcache === true);
|
||||
const main = images.filter(({ inSubcache }) => inSubcache !== true);
|
||||
const mappingTable = headerSize;
|
||||
const imageTable = mappingTable + 32;
|
||||
const subTable = imageTable + images.length * 32;
|
||||
const pathsAt = subTable + (sub.length > 0 && !legacy ? 56 : 0);
|
||||
const paths = images.map(({ path }) => Buffer.from(`${path}\0`));
|
||||
let cursor = align(
|
||||
pathsAt + paths.reduce((total, path) => total + path.length, 0),
|
||||
);
|
||||
const mainOffsets = main.map((image) => {
|
||||
const offset = cursor;
|
||||
cursor = align(cursor + image.bytes.length);
|
||||
return offset;
|
||||
});
|
||||
const mainFile = Buffer.alloc(cursor);
|
||||
writeHeader(mainFile, { headerSize, uuidSeed: 1, architecture });
|
||||
writeMapping(mainFile, mappingTable, MAIN_BASE, mainFile.length);
|
||||
if (legacy) {
|
||||
mainFile.writeUInt32LE(imageTable, 0x18);
|
||||
mainFile.writeUInt32LE(images.length, 0x1c);
|
||||
} else {
|
||||
mainFile.writeUInt32LE(1, 0xd8);
|
||||
mainFile.writeBigUInt64LE(MAIN_BASE, 0xe0);
|
||||
mainFile.writeBigUInt64LE(0x20000000n, 0xe8);
|
||||
mainFile.writeBigUInt64LE(0x10000000n, 0xf0);
|
||||
mainFile.writeUInt32LE(0x001a0600, 0x16c);
|
||||
mainFile.writeUInt32LE(imageTable, 0x1c0);
|
||||
mainFile.writeUInt32LE(images.length, 0x1c4);
|
||||
}
|
||||
let pathCursor = pathsAt;
|
||||
let subCursor = MODERN_HEADER + 32;
|
||||
const subOffsets = new Map<string, number>();
|
||||
for (const image of sub) {
|
||||
subCursor = align(subCursor);
|
||||
subOffsets.set(image.path, subCursor);
|
||||
subCursor += image.bytes.length;
|
||||
}
|
||||
images.forEach((image, index) => {
|
||||
const row = imageTable + index * 32;
|
||||
const mainIndex = main.indexOf(image);
|
||||
const address =
|
||||
mainIndex >= 0
|
||||
? MAIN_BASE + BigInt(mainOffsets[mainIndex] ?? 0)
|
||||
: SUB_BASE + BigInt(subOffsets.get(image.path) ?? 0);
|
||||
mainFile.writeBigUInt64LE(address, row);
|
||||
mainFile.writeUInt32LE(pathCursor, row + 24);
|
||||
const path = paths[index] ?? Buffer.alloc(1);
|
||||
path.copy(mainFile, pathCursor);
|
||||
pathCursor += path.length;
|
||||
if (mainIndex >= 0)
|
||||
Buffer.from(image.bytes).copy(mainFile, mainOffsets[mainIndex] ?? 0);
|
||||
});
|
||||
if (sub.length === 0 || legacy) return { main: mainFile, subcaches: [] };
|
||||
mainFile.writeUInt32LE(subTable, 0x188);
|
||||
mainFile.writeUInt32LE(1, 0x18c);
|
||||
uuid(2).copy(mainFile, subTable);
|
||||
mainFile.writeBigUInt64LE(SUB_BASE - MAIN_BASE, subTable + 16);
|
||||
mainFile.write(".01", subTable + 24, "latin1");
|
||||
const subFile = Buffer.alloc(align(subCursor));
|
||||
writeHeader(subFile, {
|
||||
headerSize: MODERN_HEADER,
|
||||
uuidSeed: options.subcacheUuidSeed ?? 2,
|
||||
architecture,
|
||||
});
|
||||
writeMapping(subFile, MODERN_HEADER, SUB_BASE, subFile.length);
|
||||
for (const image of sub)
|
||||
Buffer.from(image.bytes).copy(subFile, subOffsets.get(image.path) ?? 0);
|
||||
return { main: mainFile, subcaches: [{ suffix: ".01", bytes: subFile }] };
|
||||
};
|
||||
@@ -0,0 +1,780 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import { open, realpath, type FileHandle } from "node:fs/promises";
|
||||
|
||||
import { applePlatform } from "../../domain/apple/applePlatforms.js";
|
||||
import type { MachoImageFacts } from "../../domain/apple/dylibResolution.js";
|
||||
import type {
|
||||
DyldCacheMapping,
|
||||
DyldCacheSubcache,
|
||||
DyldSharedCacheHeader,
|
||||
} from "../../domain/apple/dyldSharedCache.js";
|
||||
import { ArtifactReaderFailure } from "../ArtifactReader.js";
|
||||
import { readMachoImage } from "./MachoLoadCommandReader.js";
|
||||
|
||||
const MAGIC_PREFIX = "dyld_v1";
|
||||
const DEVELOPMENT_EXTENSION = ".development";
|
||||
/** Header fields are valid only below `mappingOffset`; real headers are under 1 KiB. */
|
||||
const MAX_HEADER_BYTES = 64 * 1024;
|
||||
const MAX_IMAGES = 200_000;
|
||||
const MAX_MAPPINGS = 64;
|
||||
const MAX_SUBCACHES = 256;
|
||||
const MAX_PATH_BYTES = 4096;
|
||||
const BLOCK_BYTES = 64 * 1024;
|
||||
const HASH_CHUNK_BYTES = 1024 * 1024;
|
||||
|
||||
/** Byte offsets of `dyld_cache_header` fields in Apple's open-source dyld. */
|
||||
const FIELD = {
|
||||
mappingOffset: 0x10,
|
||||
mappingCount: 0x14,
|
||||
imagesOffsetOld: 0x18,
|
||||
imagesCountOld: 0x1c,
|
||||
uuid: 0x58,
|
||||
cacheType: 0x68,
|
||||
platform: 0xd8,
|
||||
formatBits: 0xdc,
|
||||
sharedRegionStart: 0xe0,
|
||||
sharedRegionSize: 0xe8,
|
||||
maxSlide: 0xf0,
|
||||
osVersion: 0x16c,
|
||||
altPlatform: 0x170,
|
||||
altOsVersion: 0x174,
|
||||
subCacheArrayOffset: 0x188,
|
||||
subCacheArrayCount: 0x18c,
|
||||
symbolFileUuid: 0x190,
|
||||
imagesOffset: 0x1c0,
|
||||
imagesCount: 0x1c4,
|
||||
cacheSubType: 0x1c8,
|
||||
} as const;
|
||||
|
||||
const hex = (value: bigint | number): string => `0x${value.toString(16)}`;
|
||||
|
||||
const uuidText = (bytes: Buffer): string => {
|
||||
const text = bytes.toString("hex").toUpperCase();
|
||||
return `${text.slice(0, 8)}-${text.slice(8, 12)}-${text.slice(12, 16)}-${text.slice(16, 20)}-${text.slice(20)}`;
|
||||
};
|
||||
|
||||
const safeNumber = (value: bigint, label: string): number => {
|
||||
if (value > BigInt(Number.MAX_SAFE_INTEGER))
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
`dyld cache ${label} exceeds the safe range`,
|
||||
);
|
||||
return Number(value);
|
||||
};
|
||||
|
||||
/** Block-cached positional reads over one cache file. */
|
||||
class CacheFile {
|
||||
readonly #blocks = new Map<number, Buffer>();
|
||||
|
||||
private constructor(
|
||||
readonly suffix: string,
|
||||
readonly handle: FileHandle,
|
||||
readonly size: number,
|
||||
/** Device, inode, size, and nanosecond change times of the file as opened. */
|
||||
readonly identity: string,
|
||||
) {}
|
||||
|
||||
static async open(path: string, suffix: string): Promise<CacheFile> {
|
||||
const handle = await open(
|
||||
await realpath(path),
|
||||
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
|
||||
);
|
||||
try {
|
||||
const metadata = await handle.stat({ bigint: true });
|
||||
if (!metadata.isFile())
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
`Dyld cache path is not a regular file: ${path}`,
|
||||
);
|
||||
return new CacheFile(
|
||||
suffix,
|
||||
handle,
|
||||
safeNumber(metadata.size, "file size"),
|
||||
CacheFile.identify(metadata),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
await handle.close();
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
static identify(metadata: {
|
||||
readonly dev: bigint;
|
||||
readonly ino: bigint;
|
||||
readonly size: bigint;
|
||||
readonly mtimeNs: bigint;
|
||||
readonly ctimeNs: bigint;
|
||||
}): string {
|
||||
return [
|
||||
metadata.dev,
|
||||
metadata.ino,
|
||||
metadata.size,
|
||||
metadata.mtimeNs,
|
||||
metadata.ctimeNs,
|
||||
].join(":");
|
||||
}
|
||||
|
||||
/** Whether the open file still matches the identity recorded at open. */
|
||||
async unchanged(): Promise<boolean> {
|
||||
return (
|
||||
CacheFile.identify(await this.handle.stat({ bigint: true })) ===
|
||||
this.identity
|
||||
);
|
||||
}
|
||||
|
||||
async read(offset: number, length: number): Promise<Buffer> {
|
||||
if (offset < 0 || offset + length > this.size)
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
`dyld cache read at ${hex(offset)} extends beyond ${this.suffix === "" ? "the main file" : `subcache ${this.suffix}`}`,
|
||||
);
|
||||
if (length > BLOCK_BYTES) {
|
||||
const bytes = Buffer.alloc(length);
|
||||
await this.readInto(bytes, 0, length, offset);
|
||||
return bytes;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
for (let position = offset; position < offset + length;) {
|
||||
const index = Math.floor(position / BLOCK_BYTES);
|
||||
let block = this.#blocks.get(index);
|
||||
if (block === undefined) {
|
||||
const start = index * BLOCK_BYTES;
|
||||
block = Buffer.alloc(Math.min(BLOCK_BYTES, this.size - start));
|
||||
await this.readInto(block, 0, block.length, start);
|
||||
this.#blocks.set(index, block);
|
||||
}
|
||||
const from = position - index * BLOCK_BYTES;
|
||||
const take = Math.min(block.length - from, offset + length - position);
|
||||
chunks.push(block.subarray(from, from + take));
|
||||
position += take;
|
||||
}
|
||||
return chunks.length === 1
|
||||
? (chunks[0] ?? Buffer.alloc(0))
|
||||
: Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
/** Positional read that retries short reads; slow filesystems may return partial data. */
|
||||
private async readInto(
|
||||
target: Buffer,
|
||||
targetOffset: number,
|
||||
length: number,
|
||||
position: number,
|
||||
): Promise<void> {
|
||||
let done = 0;
|
||||
while (done < length) {
|
||||
const { bytesRead } = await this.handle.read(
|
||||
target,
|
||||
targetOffset + done,
|
||||
length - done,
|
||||
position + done,
|
||||
);
|
||||
if (bytesRead === 0)
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
`dyld cache read at ${hex(position)} is truncated`,
|
||||
);
|
||||
done += bytesRead;
|
||||
}
|
||||
}
|
||||
|
||||
async cString(offset: number): Promise<string> {
|
||||
const bytes = await this.read(
|
||||
offset,
|
||||
Math.min(MAX_PATH_BYTES, this.size - offset),
|
||||
);
|
||||
const end = bytes.indexOf(0);
|
||||
if (end < 0)
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
`dyld cache string at ${hex(offset)} is unterminated`,
|
||||
);
|
||||
return bytes.toString("utf8", 0, end);
|
||||
}
|
||||
}
|
||||
|
||||
interface CacheRegion {
|
||||
readonly file: CacheFile;
|
||||
readonly address: number;
|
||||
readonly size: number;
|
||||
readonly fileOffset: number;
|
||||
}
|
||||
|
||||
/** Validated disjoint regions are sorted once; all VM lookups use logarithmic search. */
|
||||
const cacheRegionAt = (
|
||||
regions: readonly CacheRegion[],
|
||||
address: number,
|
||||
): CacheRegion | undefined => {
|
||||
let low = 0;
|
||||
let high = regions.length;
|
||||
while (low < high) {
|
||||
const middle = Math.floor((low + high) / 2);
|
||||
const region = regions[middle];
|
||||
if (region !== undefined && region.address <= address) low = middle + 1;
|
||||
else high = middle;
|
||||
}
|
||||
const region = regions[low - 1];
|
||||
return region !== undefined && address < region.address + region.size
|
||||
? region
|
||||
: undefined;
|
||||
};
|
||||
|
||||
interface ParsedHeader {
|
||||
readonly magic: string;
|
||||
readonly uuid: string;
|
||||
readonly mappings: readonly {
|
||||
readonly address: bigint;
|
||||
readonly size: bigint;
|
||||
readonly fileOffset: bigint;
|
||||
readonly maxProt: number;
|
||||
readonly initProt: number;
|
||||
}[];
|
||||
readonly header: Buffer;
|
||||
readonly length: number;
|
||||
}
|
||||
|
||||
const has = (parsed: ParsedHeader, offset: number, width: number): boolean =>
|
||||
parsed.length >= offset + width;
|
||||
|
||||
const readHeader = async (file: CacheFile): Promise<ParsedHeader> => {
|
||||
if (file.size < 0x20)
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
"File is too short to be a dyld shared cache",
|
||||
);
|
||||
const prefix = await file.read(0, 0x20);
|
||||
const magic = prefix.toString("latin1", 0, 16).replace(/\0+$/u, "");
|
||||
if (!magic.startsWith(MAGIC_PREFIX))
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
"File is not a dyld shared cache",
|
||||
);
|
||||
const mappingOffset = prefix.readUInt32LE(FIELD.mappingOffset);
|
||||
const mappingCount = prefix.readUInt32LE(FIELD.mappingCount);
|
||||
if (
|
||||
mappingOffset < 0x20 ||
|
||||
mappingOffset > MAX_HEADER_BYTES ||
|
||||
mappingCount === 0 ||
|
||||
mappingCount > MAX_MAPPINGS
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
"dyld cache header has an invalid mapping table",
|
||||
);
|
||||
const header = await file.read(0, mappingOffset);
|
||||
const table = await file.read(mappingOffset, mappingCount * 32);
|
||||
const mappings = Array.from({ length: mappingCount }, (_, index) => ({
|
||||
address: table.readBigUInt64LE(index * 32),
|
||||
size: table.readBigUInt64LE(index * 32 + 8),
|
||||
fileOffset: table.readBigUInt64LE(index * 32 + 16),
|
||||
maxProt: table.readUInt32LE(index * 32 + 24),
|
||||
initProt: table.readUInt32LE(index * 32 + 28),
|
||||
}));
|
||||
const parsed = { magic, uuid: "", mappings, header, length: mappingOffset };
|
||||
return {
|
||||
...parsed,
|
||||
uuid: has(parsed, FIELD.uuid, 16)
|
||||
? uuidText(header.subarray(FIELD.uuid, FIELD.uuid + 16))
|
||||
: "",
|
||||
};
|
||||
};
|
||||
|
||||
const cacheArchitecture = (magic: string): string =>
|
||||
magic.slice(MAGIC_PREFIX.length).trim();
|
||||
|
||||
/** Lowest mapping address: the subcache base `cacheVMOffset` is measured from. */
|
||||
const mappingBase = (parsed: ParsedHeader): bigint => {
|
||||
const first = parsed.mappings[0];
|
||||
if (first === undefined)
|
||||
throw new ArtifactReaderFailure("format", "dyld cache has no mappings");
|
||||
return parsed.mappings.reduce(
|
||||
(lowest, mapping) => (mapping.address < lowest ? mapping.address : lowest),
|
||||
first.address,
|
||||
);
|
||||
};
|
||||
|
||||
const version = (value: number): string | null =>
|
||||
value === 0
|
||||
? null
|
||||
: `${value >>> 16}.${(value >>> 8) & 0xff}.${value & 0xff}`;
|
||||
|
||||
/** dyld_cache_header.formatBits simulator bit follows the 8-bit version and disk flag. */
|
||||
const SIMULATOR_CACHE_FLAG = 1 << 9;
|
||||
const SIMULATOR_PLATFORMS: ReadonlyMap<number, number> = new Map([
|
||||
[2, 7],
|
||||
[3, 8],
|
||||
[4, 9],
|
||||
[11, 12],
|
||||
]);
|
||||
|
||||
const cachePlatform = (id: number | null, simulator: boolean | null) =>
|
||||
id === null
|
||||
? null
|
||||
: applePlatform(
|
||||
simulator === true ? (SIMULATOR_PLATFORMS.get(id) ?? id) : id,
|
||||
);
|
||||
|
||||
/**
|
||||
* Decode `cacheType` according to header generation. Modern headers
|
||||
* (those carrying `cacheSubType`) use `0 = development`, `1 = production`;
|
||||
* older single-file headers use the historical `1 = development`,
|
||||
* `0 = optimized` polarity.
|
||||
*/
|
||||
const decodeCacheType = (
|
||||
cacheType: number | null,
|
||||
modern: boolean,
|
||||
): DyldSharedCacheHeader["cache_type"] => {
|
||||
if (cacheType === null) return null;
|
||||
if (modern)
|
||||
return cacheType === 0
|
||||
? "development"
|
||||
: cacheType === 1
|
||||
? "production"
|
||||
: cacheType === 2
|
||||
? "multi-cache"
|
||||
: null;
|
||||
return cacheType === 1
|
||||
? "development"
|
||||
: cacheType === 0
|
||||
? "production"
|
||||
: null;
|
||||
};
|
||||
|
||||
/** A main dyld shared cache file and the subcaches it names. */
|
||||
export class DyldSharedCache {
|
||||
readonly #byPath: ReadonlyMap<
|
||||
string,
|
||||
{ readonly path: string; readonly address: number }
|
||||
>;
|
||||
|
||||
private constructor(
|
||||
readonly header: DyldSharedCacheHeader,
|
||||
readonly images: readonly {
|
||||
readonly path: string;
|
||||
readonly address: number;
|
||||
}[],
|
||||
private readonly files: readonly CacheFile[],
|
||||
private readonly regions: readonly CacheRegion[],
|
||||
) {
|
||||
this.#byPath = new Map(images.map((image) => [image.path, image]));
|
||||
}
|
||||
|
||||
/** Open the main cache file and every subcache beside it. */
|
||||
static async open(
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<DyldSharedCache> {
|
||||
// Inspection and tracing share this boundary: companion discovery follows
|
||||
// the actual main cache file, while adapters keep the selected path spelling.
|
||||
const canonicalPath = await realpath(path);
|
||||
const main = await CacheFile.open(canonicalPath, "");
|
||||
const files = [main];
|
||||
try {
|
||||
const parsed = await readHeader(main);
|
||||
const { named, entries: subcaches } =
|
||||
await DyldSharedCache.#subcacheEntries(main, parsed);
|
||||
// With named suffixes, dyld strips a development main file's extension
|
||||
// first: dyld_shared_cache_arm64e.development -> dyld_shared_cache_arm64e.01.development.
|
||||
const base =
|
||||
named && canonicalPath.endsWith(DEVELOPMENT_EXTENSION)
|
||||
? canonicalPath.slice(0, -DEVELOPMENT_EXTENSION.length)
|
||||
: canonicalPath;
|
||||
const statuses: DyldCacheSubcache[] = [];
|
||||
const parsedFiles = [{ file: main, parsed }];
|
||||
for (const entry of subcaches) {
|
||||
signal?.throwIfAborted();
|
||||
let file: CacheFile;
|
||||
try {
|
||||
file = await CacheFile.open(`${base}${entry.suffix}`, entry.suffix);
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
cause instanceof Error &&
|
||||
"code" in cause &&
|
||||
cause.code === "ENOENT"
|
||||
) {
|
||||
statuses.push({ ...entry, status: "missing", observed_uuid: null });
|
||||
continue;
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
files.push(file);
|
||||
const subParsed = await readHeader(file);
|
||||
// UUID, architecture, and VM offset are one admission decision.
|
||||
// A later overlap or image-address check cannot repair a companion
|
||||
// that already contradicts the main header.
|
||||
const status = DyldSharedCache.#admitSubcache(parsed, entry, subParsed);
|
||||
statuses.push({
|
||||
...entry,
|
||||
status,
|
||||
observed_uuid: subParsed.uuid,
|
||||
});
|
||||
if (status === "present") parsedFiles.push({ file, parsed: subParsed });
|
||||
}
|
||||
const header = DyldSharedCache.#summary(parsed, parsedFiles, statuses);
|
||||
const images = await DyldSharedCache.#images(main, parsed, signal);
|
||||
const regions = parsedFiles.flatMap(({ file, parsed: item }) =>
|
||||
item.mappings.map((mapping) => {
|
||||
const fileOffset = safeNumber(
|
||||
mapping.fileOffset,
|
||||
"mapping file offset",
|
||||
);
|
||||
const size = safeNumber(mapping.size, "mapping size");
|
||||
const address = safeNumber(mapping.address, "mapping address");
|
||||
// A truncated file that declares mappings beyond its bytes cannot
|
||||
// map those addresses; drop the unverified extent.
|
||||
if (
|
||||
fileOffset < 0 ||
|
||||
size <= 0 ||
|
||||
fileOffset + size > file.size ||
|
||||
!Number.isSafeInteger(address + size)
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
`dyld cache mapping in ${file.suffix === "" ? "the main file" : `subcache ${file.suffix}`} extends beyond its file`,
|
||||
);
|
||||
return {
|
||||
file,
|
||||
address,
|
||||
size,
|
||||
fileOffset,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const ordered = regions.toSorted(
|
||||
(left, right) => left.address - right.address,
|
||||
);
|
||||
for (let index = 1; index < ordered.length; index++) {
|
||||
const previous = ordered[index - 1];
|
||||
const current = ordered[index];
|
||||
if (
|
||||
previous !== undefined &&
|
||||
current !== undefined &&
|
||||
current.address < previous.address + previous.size
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
`Dyld cache VM mappings overlap at ${hex(current.address)} between ${previous.file.suffix || "main"} and ${current.file.suffix || "main"}`,
|
||||
);
|
||||
}
|
||||
if (statuses.every(({ status }) => status === "present")) {
|
||||
const unmapped = images.find(
|
||||
({ address }) => cacheRegionAt(ordered, address) === undefined,
|
||||
);
|
||||
if (unmapped !== undefined)
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
`Complete dyld cache has an unmapped image address ${hex(unmapped.address)}: ${unmapped.path}`,
|
||||
);
|
||||
}
|
||||
return new DyldSharedCache(header, images, files, ordered);
|
||||
} catch (cause: unknown) {
|
||||
await Promise.allSettled(files.map(({ handle }) => handle.close()));
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
static async #subcacheEntries(
|
||||
main: CacheFile,
|
||||
parsed: ParsedHeader,
|
||||
): Promise<{
|
||||
/** Entries carry explicit file suffixes (dyld_subcache_entry v2). */
|
||||
readonly named: boolean;
|
||||
readonly entries: {
|
||||
readonly suffix: string;
|
||||
readonly uuid: string;
|
||||
readonly vm_offset: string;
|
||||
}[];
|
||||
}> {
|
||||
if (!has(parsed, FIELD.subCacheArrayCount, 4))
|
||||
return { named: false, entries: [] };
|
||||
const offset = parsed.header.readUInt32LE(FIELD.subCacheArrayOffset);
|
||||
const count = parsed.header.readUInt32LE(FIELD.subCacheArrayCount);
|
||||
// Headers that include cacheSubType use entries with an explicit file suffix.
|
||||
const named = has(parsed, FIELD.cacheSubType, 4);
|
||||
if (count === 0) return { named, entries: [] };
|
||||
if (count > MAX_SUBCACHES)
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
"dyld cache lists too many subcaches",
|
||||
);
|
||||
const size = named ? 56 : 24;
|
||||
const table = await main.read(offset, count * size);
|
||||
const entries = Array.from({ length: count }, (_, index) => {
|
||||
const base = index * size;
|
||||
const suffix = named
|
||||
? table.toString("latin1", base + 24, base + 56).replace(/\0.*$/su, "")
|
||||
: `.${index + 1}`;
|
||||
if (!/^\.[A-Za-z0-9.]+$/u.test(suffix))
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
"dyld subcache has an unsafe file suffix",
|
||||
);
|
||||
return {
|
||||
suffix,
|
||||
uuid: uuidText(table.subarray(base, base + 16)),
|
||||
vm_offset: hex(table.readBigUInt64LE(base + 16)),
|
||||
};
|
||||
});
|
||||
return { named, entries };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a companion file is the subcache the main header named.
|
||||
* UUID, magic, and `cacheVMOffset` are checked together; failure to match
|
||||
* the UUID leaves the cache partial, while a matching UUID that contradicts
|
||||
* the main header is a format error and contributes no mappings.
|
||||
*/
|
||||
static #admitSubcache(
|
||||
main: ParsedHeader,
|
||||
entry: {
|
||||
readonly suffix: string;
|
||||
readonly uuid: string;
|
||||
readonly vm_offset: string;
|
||||
},
|
||||
sub: ParsedHeader,
|
||||
): "present" | "uuid-mismatch" {
|
||||
if (sub.uuid !== entry.uuid) return "uuid-mismatch";
|
||||
if (sub.magic !== main.magic)
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
`dyld subcache ${entry.suffix} architecture ${cacheArchitecture(sub.magic)} does not match the main cache architecture ${cacheArchitecture(main.magic)}`,
|
||||
);
|
||||
const mainBase = mappingBase(main);
|
||||
const actual = mappingBase(sub);
|
||||
const expected = mainBase + BigInt(entry.vm_offset);
|
||||
if (actual !== expected)
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
`dyld subcache ${entry.suffix} mapping base ${hex(actual)} disagrees with cache VM offset ${entry.vm_offset} from main base ${hex(mainBase)}`,
|
||||
);
|
||||
return "present";
|
||||
}
|
||||
|
||||
static #summary(
|
||||
parsed: ParsedHeader,
|
||||
files: readonly {
|
||||
readonly file: CacheFile;
|
||||
readonly parsed: ParsedHeader;
|
||||
}[],
|
||||
subcaches: readonly DyldCacheSubcache[],
|
||||
): DyldSharedCacheHeader {
|
||||
const header = parsed.header;
|
||||
const u32 = (offset: number): number | null =>
|
||||
has(parsed, offset, 4) ? header.readUInt32LE(offset) : null;
|
||||
const u64 = (offset: number): string | null =>
|
||||
has(parsed, offset, 8) ? hex(header.readBigUInt64LE(offset)) : null;
|
||||
const platform = u32(FIELD.platform);
|
||||
const altPlatform = u32(FIELD.altPlatform);
|
||||
const formatBits = u32(FIELD.formatBits);
|
||||
const simulator =
|
||||
formatBits === null ? null : (formatBits & SIMULATOR_CACHE_FLAG) !== 0;
|
||||
const cacheType = has(parsed, FIELD.cacheType, 8)
|
||||
? Number(header.readBigUInt64LE(FIELD.cacheType))
|
||||
: null;
|
||||
const cacheSubType = cacheType === 2 ? u32(FIELD.cacheSubType) : null;
|
||||
const symbols = has(parsed, FIELD.symbolFileUuid, 16)
|
||||
? header.subarray(FIELD.symbolFileUuid, FIELD.symbolFileUuid + 16)
|
||||
: undefined;
|
||||
const mappings: DyldCacheMapping[] = files.flatMap(
|
||||
({ file, parsed: item }) =>
|
||||
item.mappings.map((mapping) => ({
|
||||
file: file.suffix,
|
||||
address: hex(mapping.address),
|
||||
size: hex(mapping.size),
|
||||
file_offset: hex(mapping.fileOffset),
|
||||
max_protection: mapping.maxProt,
|
||||
initial_protection: mapping.initProt,
|
||||
})),
|
||||
);
|
||||
return {
|
||||
magic: parsed.magic,
|
||||
architecture: cacheArchitecture(parsed.magic),
|
||||
uuid: parsed.uuid,
|
||||
platform: cachePlatform(platform, simulator),
|
||||
header_platform: platform === null ? null : applePlatform(platform),
|
||||
header_alt_platform:
|
||||
altPlatform === null || altPlatform === 0
|
||||
? null
|
||||
: applePlatform(altPlatform),
|
||||
simulator,
|
||||
os_version: version(u32(FIELD.osVersion) ?? 0),
|
||||
alt_platform:
|
||||
altPlatform === null || altPlatform === 0
|
||||
? null
|
||||
: cachePlatform(altPlatform, simulator),
|
||||
alt_os_version: version(u32(FIELD.altOsVersion) ?? 0),
|
||||
cache_type: decodeCacheType(
|
||||
cacheType,
|
||||
has(parsed, FIELD.cacheSubType, 4),
|
||||
),
|
||||
cache_subtype:
|
||||
cacheSubType !== null
|
||||
? {
|
||||
id: cacheSubType,
|
||||
name:
|
||||
cacheSubType === 0
|
||||
? "development"
|
||||
: cacheSubType === 1
|
||||
? "production"
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
shared_region:
|
||||
u64(FIELD.sharedRegionStart) === null
|
||||
? null
|
||||
: {
|
||||
start: u64(FIELD.sharedRegionStart) ?? "0x0",
|
||||
size: u64(FIELD.sharedRegionSize) ?? "0x0",
|
||||
},
|
||||
max_slide: u64(FIELD.maxSlide),
|
||||
mappings,
|
||||
subcaches: [...subcaches],
|
||||
symbols_file_uuid:
|
||||
symbols === undefined || symbols.every((byte) => byte === 0)
|
||||
? null
|
||||
: uuidText(symbols),
|
||||
};
|
||||
}
|
||||
|
||||
static async #images(
|
||||
main: CacheFile,
|
||||
parsed: ParsedHeader,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ readonly path: string; readonly address: number }[]> {
|
||||
const modern = has(parsed, FIELD.imagesCount, 4);
|
||||
const offset = parsed.header.readUInt32LE(
|
||||
modern ? FIELD.imagesOffset : FIELD.imagesOffsetOld,
|
||||
);
|
||||
const count = parsed.header.readUInt32LE(
|
||||
modern ? FIELD.imagesCount : FIELD.imagesCountOld,
|
||||
);
|
||||
if (count > MAX_IMAGES)
|
||||
throw new ArtifactReaderFailure(
|
||||
"limit",
|
||||
`dyld cache lists more than ${MAX_IMAGES} images`,
|
||||
);
|
||||
const table = await main.read(offset, count * 32);
|
||||
const images: { path: string; address: number }[] = [];
|
||||
for (let index = 0; index < count; index++) {
|
||||
if (index % 512 === 0) signal?.throwIfAborted();
|
||||
images.push({
|
||||
address: safeNumber(table.readBigUInt64LE(index * 32), "image address"),
|
||||
path: await main.cString(table.readUInt32LE(index * 32 + 24)),
|
||||
});
|
||||
}
|
||||
return images;
|
||||
}
|
||||
|
||||
/** Locate an image by its exact install path. */
|
||||
find(
|
||||
path: string,
|
||||
): { readonly path: string; readonly address: number } | undefined {
|
||||
return this.#byPath.get(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an install path is listed and its address lies in a mapping of an
|
||||
* admitted file. This is address coverage: `imageFacts` has to parse a
|
||||
* compatible Mach-O before the path is a shared-cache load.
|
||||
*/
|
||||
locate(path: string): "mapped" | "unverified" | "absent" {
|
||||
const image = this.find(path);
|
||||
if (image === undefined) return "absent";
|
||||
return this.#region(image.address) === undefined ? "unverified" : "mapped";
|
||||
}
|
||||
|
||||
#region(address: number) {
|
||||
return cacheRegionAt(this.regions, address);
|
||||
}
|
||||
|
||||
/** Parse one cached image's load commands through the cache's VM mappings. */
|
||||
async imageFacts(
|
||||
path: string,
|
||||
): Promise<
|
||||
{ readonly file: string; readonly facts: MachoImageFacts } | undefined
|
||||
> {
|
||||
const image = this.find(path);
|
||||
if (image === undefined) return undefined;
|
||||
const region = this.#region(image.address);
|
||||
if (region === undefined) return undefined;
|
||||
const base = region.fileOffset + (image.address - region.address);
|
||||
const available = region.size - (image.address - region.address);
|
||||
const facts = await readMachoImage(
|
||||
async (offset, length) =>
|
||||
region.file.read(
|
||||
base + offset,
|
||||
Math.max(0, Math.min(length, available - offset)),
|
||||
),
|
||||
available,
|
||||
);
|
||||
return { file: region.file.suffix, facts };
|
||||
}
|
||||
|
||||
/** Digest of the main file through the handle its header was parsed from. */
|
||||
async mainSha256(signal?: AbortSignal): Promise<string> {
|
||||
const main = this.files[0];
|
||||
if (main === undefined)
|
||||
throw new ArtifactReaderFailure("format", "dyld cache has no main file");
|
||||
return this.hashFile(main, signal);
|
||||
}
|
||||
|
||||
/** SHA-256 of every readable subcache, keyed by suffix, through their open handles. */
|
||||
async subcacheSha256(signal?: AbortSignal): Promise<Record<string, string>> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const file of this.files.slice(1)) {
|
||||
signal?.throwIfAborted();
|
||||
out[file.suffix] = await this.hashFile(file, signal);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private async hashFile(
|
||||
file: CacheFile,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
// In-place rewrites through the same inode keep this handle valid, so a
|
||||
// digest is bound to its bytes only when the identity is unchanged.
|
||||
if (!(await file.unchanged()))
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
"dyld cache file changed before its digest was read",
|
||||
);
|
||||
const hash = createHash("sha256");
|
||||
const buffer = Buffer.alloc(HASH_CHUNK_BYTES);
|
||||
let position = 0;
|
||||
for (;;) {
|
||||
signal?.throwIfAborted();
|
||||
if (position >= file.size) break;
|
||||
const { bytesRead } = await file.handle.read(
|
||||
buffer,
|
||||
0,
|
||||
Math.min(buffer.length, file.size - position),
|
||||
position,
|
||||
);
|
||||
// A file truncated after open must not yield the surviving prefix's
|
||||
// digest as the identity of the recorded size.
|
||||
if (bytesRead === 0)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
"dyld cache file changed while its digest was read",
|
||||
);
|
||||
hash.update(buffer.subarray(0, bytesRead));
|
||||
position += bytesRead;
|
||||
}
|
||||
if (position !== file.size)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
"dyld cache file changed while its digest was read",
|
||||
);
|
||||
if (!(await file.unchanged()))
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
"dyld cache file changed while its digest was read",
|
||||
);
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await Promise.allSettled(this.files.map(({ handle }) => handle.close()));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import { lstat, open, readlink, realpath } from "node:fs/promises";
|
||||
import { join, relative, sep } from "node:path";
|
||||
import { join, relative, resolve, sep } from "node:path";
|
||||
|
||||
import { resolveTreePath } from "../../domain/apple/dyldPaths.js";
|
||||
import {
|
||||
@@ -9,12 +9,18 @@ import {
|
||||
dylibResolutionResultSchema,
|
||||
traceDylibLoading,
|
||||
type DylibResolutionResult,
|
||||
type DylibSharedCacheView,
|
||||
type DylibTreeEntry,
|
||||
type DylibTreeView,
|
||||
type MachoImageFacts,
|
||||
} from "../../domain/apple/dylibResolution.js";
|
||||
import { ArtifactReaderFailure } from "../ArtifactReader.js";
|
||||
import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js";
|
||||
import { DyldSharedCache } from "./DyldSharedCacheReader.js";
|
||||
import {
|
||||
filesystemDyldLinkTarget,
|
||||
filesystemDyldLookupPath,
|
||||
} from "./FilesystemDyldPaths.js";
|
||||
import { hasMachoMagic, readMachoImage } from "./MachoLoadCommandReader.js";
|
||||
|
||||
const HASH_CHUNK_BYTES = 1024 * 1024;
|
||||
@@ -61,11 +67,14 @@ class FilesystemTreeView implements DylibTreeView {
|
||||
|
||||
async #readEntry(path: string): Promise<DylibTreeEntry | undefined> {
|
||||
cancelled(this.signal);
|
||||
const absolute = join(this.root, path);
|
||||
const absolute = filesystemDyldLookupPath(this.root, path);
|
||||
try {
|
||||
const metadata = await lstat(absolute);
|
||||
if (metadata.isSymbolicLink())
|
||||
return { kind: "symlink", target: await readlink(absolute) };
|
||||
return {
|
||||
kind: "symlink",
|
||||
target: filesystemDyldLinkTarget(await readlink(absolute)),
|
||||
};
|
||||
if (metadata.isDirectory()) return { kind: "directory" };
|
||||
return metadata.isFile() ? { kind: "file" } : undefined;
|
||||
} catch (cause: unknown) {
|
||||
@@ -85,11 +94,18 @@ export const traceDylibResolution = async (options: {
|
||||
readonly signal?: AbortSignal;
|
||||
}): Promise<DylibResolutionResult> => {
|
||||
const parsed = dylibResolutionInputSchema.safeParse(options.parameters);
|
||||
if (!parsed.success)
|
||||
if (!parsed.success) {
|
||||
const issue = parsed.error.issues[0];
|
||||
const at =
|
||||
issue !== undefined && issue.path.length > 0
|
||||
? ` (${issue.path.join(".")} ${issue.message})`
|
||||
: "";
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
"roots must be normalized paths relative to the analyzed root",
|
||||
`dylib resolution parameters are invalid${at}`,
|
||||
);
|
||||
}
|
||||
let cache: DyldSharedCache | undefined;
|
||||
// Canonicalization is inside the translated region: a revoked permission
|
||||
// keeps its reason and path.
|
||||
let root = options.rootPath;
|
||||
@@ -99,6 +115,7 @@ export const traceDylibResolution = async (options: {
|
||||
.split(sep)
|
||||
.join("/");
|
||||
const view = new FilesystemTreeView(root, options.signal);
|
||||
cache = await openSharedCache(parsed.data.shared_cache, options.signal);
|
||||
const { roots: requested, unclassified } =
|
||||
parsed.data.roots !== undefined
|
||||
? { roots: parsed.data.roots, unclassified: [] }
|
||||
@@ -112,6 +129,7 @@ export const traceDylibResolution = async (options: {
|
||||
...(parsed.data.architecture === undefined
|
||||
? {}
|
||||
: { architecture: parsed.data.architecture }),
|
||||
...(cache === undefined ? {} : { sharedCache: sharedCacheView(cache) }),
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
});
|
||||
// Each digest must describe the same file whose headers were parsed.
|
||||
@@ -137,10 +155,24 @@ export const traceDylibResolution = async (options: {
|
||||
"integrity",
|
||||
`Active target digest changed: expected ${options.targetSha256}, observed ${targetDigest}`,
|
||||
);
|
||||
// Bind the trace to the cache bytes that produced it: a replaced cache
|
||||
// can retain header UUID/architecture while changing resolutions.
|
||||
const cacheIdentity =
|
||||
cache === undefined || parsed.data.shared_cache === undefined
|
||||
? null
|
||||
: {
|
||||
path: parsed.data.shared_cache,
|
||||
uuid: cache.header.uuid,
|
||||
architecture: cache.header.architecture,
|
||||
os_version: cache.header.os_version,
|
||||
main_file_sha256: await cache.mainSha256(options.signal),
|
||||
subcache_sha256: await cache.subcacheSha256(options.signal),
|
||||
};
|
||||
return dylibResolutionResultSchema.parse({
|
||||
...trace,
|
||||
root_path: options.rootPath,
|
||||
target_sha256: options.targetSha256,
|
||||
shared_cache: cacheIdentity,
|
||||
images: trace.images.map((image) => ({
|
||||
...image,
|
||||
sha256: digests.get(image.path),
|
||||
@@ -161,9 +193,41 @@ export const traceDylibResolution = async (options: {
|
||||
{ cause },
|
||||
);
|
||||
throw cause;
|
||||
} finally {
|
||||
await cache?.close();
|
||||
}
|
||||
};
|
||||
|
||||
const openSharedCache = async (
|
||||
path: string | undefined,
|
||||
signal?: AbortSignal,
|
||||
): Promise<DyldSharedCache | undefined> => {
|
||||
if (path === undefined) return undefined;
|
||||
try {
|
||||
return await DyldSharedCache.open(resolve(path), signal);
|
||||
} catch (cause: unknown) {
|
||||
if (missing(cause))
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
"shared_cache does not name an existing dyld shared cache file",
|
||||
{ cause },
|
||||
);
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
const sharedCacheView = (cache: DyldSharedCache): DylibSharedCacheView => ({
|
||||
architecture: cache.header.architecture,
|
||||
platforms: [cache.header.platform, cache.header.alt_platform].filter(
|
||||
(platform) => platform !== null,
|
||||
),
|
||||
unavailableSubcaches: cache.header.subcaches
|
||||
.filter(({ status }) => status !== "present")
|
||||
.map(({ suffix, status }) => `${suffix} (${status})`),
|
||||
lookup: (path) => cache.locate(path),
|
||||
image: async (path) => (await cache.imageFacts(path))?.facts,
|
||||
});
|
||||
|
||||
/**
|
||||
* Every Mach-O in the bundle with an executable slice is its own process root.
|
||||
* Mach-O files that do not parse are returned separately: whether they are
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { resolveTreePath } from "../../domain/apple/dyldPaths.js";
|
||||
import type { DylibTreeEntry } from "../../domain/apple/dylibResolution.js";
|
||||
import {
|
||||
filesystemDyldLinkTarget,
|
||||
filesystemDyldLookupPath,
|
||||
} from "./FilesystemDyldPaths.js";
|
||||
|
||||
it("normalizes Windows relative links before containment checks without probing an escaped path", async () => {
|
||||
const requested: string[] = [];
|
||||
const result = await resolveTreePath(
|
||||
{
|
||||
entry: async (path: string): Promise<DylibTreeEntry | undefined> => {
|
||||
requested.push(path);
|
||||
return path === "link"
|
||||
? {
|
||||
kind: "symlink",
|
||||
target: filesystemDyldLinkTarget("..\\outside.dylib", "win32"),
|
||||
}
|
||||
: undefined;
|
||||
},
|
||||
image: async () => ({ status: "not-mach-o" }),
|
||||
},
|
||||
"link",
|
||||
);
|
||||
expect(result).toEqual({ kind: "escapes" });
|
||||
expect(requested).toEqual(["link"]);
|
||||
});
|
||||
|
||||
it("keeps POSIX backslash bytes and preserves Windows dot segments for segment-wise traversal", () => {
|
||||
expect(filesystemDyldLinkTarget("..\\outside.dylib", "darwin")).toBe(
|
||||
"..\\outside.dylib",
|
||||
);
|
||||
expect(filesystemDyldLinkTarget("nested\\..\\target", "win32")).toBe(
|
||||
"nested/../target",
|
||||
);
|
||||
expect(filesystemDyldLinkTarget("C:\\outside", "win32")).toBe("/C:/outside");
|
||||
expect(filesystemDyldLinkTarget("C:/literal-posix-directory", "darwin")).toBe(
|
||||
"C:/literal-posix-directory",
|
||||
);
|
||||
expect(() => filesystemDyldLinkTarget("C:drive-relative", "win32")).toThrow(
|
||||
"Windows drive working directory",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not let Windows reinterpret a literal backslash-bearing loader name as traversal", () => {
|
||||
expect(() =>
|
||||
filesystemDyldLookupPath("C:\\root", "..\\outside.dylib", "win32"),
|
||||
).toThrow("cannot be represented on Windows");
|
||||
expect(
|
||||
filesystemDyldLookupPath("/root", "literal\\name.dylib", "darwin"),
|
||||
).toBe("/root/literal\\name.dylib");
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { posix, win32 } from "node:path";
|
||||
import { ArtifactReaderFailure } from "../ArtifactReader.js";
|
||||
|
||||
/**
|
||||
* Convert host filesystem separators into the tree view's POSIX traversal
|
||||
* namespace. Dot segments stay intact so they apply after symlink expansion;
|
||||
* POSIX backslash filename bytes are never reinterpreted as separators.
|
||||
*/
|
||||
export const filesystemDyldLinkTarget = (
|
||||
reported: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): string => {
|
||||
if (platform !== "win32") return reported;
|
||||
const portable = reported.replaceAll("\\", "/");
|
||||
if (win32.isAbsolute(reported))
|
||||
return portable.startsWith("/") ? portable : `/${portable}`;
|
||||
if (/^[A-Za-z]:/u.test(reported))
|
||||
throw new ArtifactReaderFailure(
|
||||
"unavailable",
|
||||
`Drive-relative symlink target depends on a Windows drive working directory: ${reported}`,
|
||||
);
|
||||
return portable;
|
||||
};
|
||||
|
||||
/** Map a portable lookup to the host without allowing extra native separators to escape it. */
|
||||
export const filesystemDyldLookupPath = (
|
||||
root: string,
|
||||
lookup: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): string => {
|
||||
if (platform === "win32" && lookup.includes("\\"))
|
||||
throw new ArtifactReaderFailure(
|
||||
"unavailable",
|
||||
`Literal backslashes in a POSIX dyld lookup cannot be represented on Windows: ${lookup}. Analyze this tree on a POSIX host.`,
|
||||
);
|
||||
return platform === "win32"
|
||||
? win32.join(root, lookup)
|
||||
: posix.join(root, lookup);
|
||||
};
|
||||
@@ -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 },
|
||||
]);
|
||||
|
||||
@@ -10,6 +10,7 @@ export const LC = {
|
||||
RPATH: 0x8000001c,
|
||||
DYLD_ENVIRONMENT: 0x27,
|
||||
CODE_SIGNATURE: 0x1d,
|
||||
BUILD_VERSION: 0x32,
|
||||
} as const;
|
||||
|
||||
export const CPU = {
|
||||
@@ -75,6 +76,18 @@ export const codeSignatureCommand = (): Uint8Array => {
|
||||
return bytes;
|
||||
};
|
||||
|
||||
/** `build_version_command` for a `PLATFORM_*` value, with no tools. */
|
||||
export const buildVersionCommand = (platform: number): Uint8Array => {
|
||||
const bytes = new Uint8Array(24);
|
||||
const view = new DataView(bytes.buffer);
|
||||
view.setUint32(0, LC.BUILD_VERSION, true);
|
||||
view.setUint32(4, 24, true);
|
||||
view.setUint32(8, platform, true);
|
||||
view.setUint32(12, 0x000f0000, true);
|
||||
view.setUint32(16, 0x001a0000, true);
|
||||
return bytes;
|
||||
};
|
||||
|
||||
/** A 64-bit (or 32-bit) little-endian Mach-O image made of the given commands. */
|
||||
export const machoImage = (options: {
|
||||
readonly cpu?: { readonly type: number; readonly subtype: number };
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
CPU,
|
||||
FILE_TYPE,
|
||||
LC,
|
||||
buildVersionCommand,
|
||||
codeSignatureCommand,
|
||||
dyldEnvironmentCommand,
|
||||
dylibCommand,
|
||||
@@ -40,6 +41,9 @@ describe("Mach-O load command reader", () => {
|
||||
rpathCommand("/usr/lib/swift"),
|
||||
dyldEnvironmentCommand("DYLD_FRAMEWORK_PATH=@executable_path"),
|
||||
codeSignatureCommand(),
|
||||
// A zippered image builds for macOS and Mac Catalyst.
|
||||
buildVersionCommand(1),
|
||||
buildVersionCommand(6),
|
||||
],
|
||||
});
|
||||
const facts = await read(image);
|
||||
@@ -54,6 +58,10 @@ describe("Mach-O load command reader", () => {
|
||||
rpaths: ["@loader_path/Frameworks", "/usr/lib/swift"],
|
||||
dyld_environment: ["DYLD_FRAMEWORK_PATH=@executable_path"],
|
||||
code_signature_present: true,
|
||||
platforms: [
|
||||
{ id: 1, name: "macos" },
|
||||
{ id: 6, name: "maccatalyst" },
|
||||
],
|
||||
});
|
||||
expect(
|
||||
slice?.dependencies.map(
|
||||
@@ -166,6 +174,37 @@ describe("Mach-O load command reader", () => {
|
||||
});
|
||||
|
||||
describe("Mach-O load command reader inputs", () => {
|
||||
it("reads platforms from LC_VERSION_MIN_* and keeps unknown ids", async () => {
|
||||
const versionMin = new Uint8Array(16);
|
||||
const view = new DataView(versionMin.buffer);
|
||||
view.setUint32(0, 0x25, true);
|
||||
view.setUint32(4, 16, true);
|
||||
const facts = await read(
|
||||
machoImage({ commands: [versionMin, buildVersionCommand(99)] }),
|
||||
);
|
||||
expect(facts.status === "parsed" && facts.slices[0]?.platforms).toEqual([
|
||||
{ id: 2, name: "ios" },
|
||||
{ id: 99, name: null },
|
||||
]);
|
||||
// Legacy version-min commands on Intel slices denote simulators.
|
||||
const intel = await read(
|
||||
machoImage({ cpu: CPU.x86_64, commands: [versionMin] }),
|
||||
);
|
||||
expect(intel.status === "parsed" && intel.slices[0]?.platforms).toEqual([
|
||||
{ id: 7, name: "ios-simulator" },
|
||||
]);
|
||||
// A 16-byte LC_BUILD_VERSION, and one that declares a tool it lacks.
|
||||
const truncated = buildVersionCommand(1).slice(0, 16);
|
||||
new DataView(truncated.buffer).setUint32(4, 16, true);
|
||||
const missingTool = buildVersionCommand(1);
|
||||
new DataView(missingTool.buffer).setUint32(20, 1, true);
|
||||
for (const command of [truncated, missingTool])
|
||||
expect(await read(machoImage({ commands: [command] }))).toEqual({
|
||||
status: "malformed",
|
||||
reason: "load command 0 is too short for a platform version",
|
||||
});
|
||||
});
|
||||
|
||||
it("names 32-bit and non-Mach-O inputs without guessing", async () => {
|
||||
const i386 = await read(
|
||||
machoImage({ cpu: { type: 7, subtype: 3 }, wide: false }),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { applePlatform } from "../../domain/apple/applePlatforms.js";
|
||||
import type {
|
||||
MachoDependency,
|
||||
MachoImageFacts,
|
||||
@@ -34,6 +35,14 @@ const LC_LOAD_UPWARD_DYLIB = 0x80000023;
|
||||
const LC_RPATH = 0x8000001c;
|
||||
const LC_DYLD_ENVIRONMENT = 0x27;
|
||||
const LC_CODE_SIGNATURE = 0x1d;
|
||||
const LC_BUILD_VERSION = 0x32;
|
||||
/** LC_VERSION_MIN_* commands imply their platform. */
|
||||
const VERSION_MIN_PLATFORMS: ReadonlyMap<number, number> = new Map([
|
||||
[0x24, 1],
|
||||
[0x25, 2],
|
||||
[0x2f, 3],
|
||||
[0x30, 4],
|
||||
]);
|
||||
|
||||
/** `mach-o/loader.h` `dylib_use_command`: `nameoff == 28` and this marker. */
|
||||
const DYLIB_USE_MARKER = 0x1a741800;
|
||||
@@ -242,6 +251,7 @@ const decodeCommands = (
|
||||
rpaths: [],
|
||||
dyld_environment: [],
|
||||
code_signature_present: false,
|
||||
platforms: [],
|
||||
};
|
||||
let offset = 0;
|
||||
for (let index = 0; index < header.commandCount; index++) {
|
||||
@@ -258,9 +268,16 @@ const decodeCommands = (
|
||||
`load command ${index} at offset ${offset} has invalid cmdsize ${size}`,
|
||||
);
|
||||
const body = bytes.subarray(offset, offset + size);
|
||||
decodeCommand(command, body, index, slice);
|
||||
decodeCommand(command, body, index, slice, header.cpuType);
|
||||
offset += size;
|
||||
}
|
||||
// A header that declares fewer commands than its sizeofcmds could hide
|
||||
// dependency loads in the unconsumed bytes.
|
||||
if (offset !== bytes.byteLength)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load commands declare ${header.commandCount} commands but sizeofcmds holds ${bytes.byteLength} bytes`,
|
||||
);
|
||||
return slice;
|
||||
};
|
||||
|
||||
@@ -269,6 +286,7 @@ const decodeCommand = (
|
||||
body: Uint8Array,
|
||||
index: number,
|
||||
slice: MachoSlice,
|
||||
cpuType: number,
|
||||
): void => {
|
||||
if (command === LC_ID_DYLIB) {
|
||||
slice.install_name = commandString(body, 24, index);
|
||||
@@ -292,6 +310,39 @@ const decodeCommand = (
|
||||
slice.code_signature_present = true;
|
||||
return;
|
||||
}
|
||||
const implied = VERSION_MIN_PLATFORMS.get(command);
|
||||
if (command === LC_BUILD_VERSION || implied !== undefined) {
|
||||
// version_min_command is 16 bytes; build_version_command is 24 bytes
|
||||
// followed by ntools 8-byte build_tool_version records.
|
||||
const required =
|
||||
implied !== undefined
|
||||
? 16
|
||||
: body.byteLength < 24
|
||||
? 24
|
||||
: 24 + viewOf(body).getUint32(20, true) * 8;
|
||||
if (body.byteLength < required)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} is too short for a platform version`,
|
||||
);
|
||||
const declared = implied ?? viewOf(body).getUint32(8, true);
|
||||
// Legacy LC_VERSION_MIN_* commands on Intel slices denote simulator
|
||||
// platforms; Apple's decoder branches on the CPU type for these commands.
|
||||
const intel = cpuType === 7 || cpuType === (7 | 0x01000000);
|
||||
const id =
|
||||
implied !== undefined && intel
|
||||
? declared === 2
|
||||
? 7
|
||||
: declared === 3
|
||||
? 8
|
||||
: declared === 4
|
||||
? 9
|
||||
: declared
|
||||
: declared;
|
||||
if (!slice.platforms.some((platform) => platform.id === id))
|
||||
slice.platforms.push(applePlatform(id));
|
||||
return;
|
||||
}
|
||||
const name = DEPENDENCY_COMMANDS.get(command);
|
||||
if (name !== undefined)
|
||||
slice.dependencies.push(decodeDependency(name, body, index));
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user