Compare commits

...
Author SHA1 Message Date
morluto 6d874f8a70 test(windows): prove permanent readonly fixture rejects missing fallback 2026-10-15 09:06:49 +08:00
morluto 0b46b37d37 test(windows): probe readonly files with no other attributes 2026-10-15 08:48:44 +08:00
morluto 84b35d8e7f test(windows): release parent write guard before admission validation 2026-10-15 08:34:44 +08:00
morluto 3afb5412fb test(windows): broaden cleanup probes and preserve primary failures 2026-10-15 08:29:01 +08:00
morluto 554898f859 test(windows): compare failed creation ownership with baseline 2026-10-15 08:21:01 +08:00
morluto f78d137b42 fix(windows): retain unpublished runtimes after cleanup failure 2026-10-15 08:16:22 +08:00
morluto 1a3a96d36b fix(windows): honor directory readonly semantics during cleanup 2026-10-15 08:16:22 +08:00
morluto fa318995f5 fix(setup): include registered IDA budgets in client deadlines 2026-10-15 07:36:34 +08:00
morluto 9a44fea347 test(mcp): avoid unrelated validator compilation in availability checks 2026-10-15 07:12:53 +08:00
morluto 2819defeea test(cli): verify attributed caveats in composed analysis 2026-10-15 06:56:09 +08:00
morluto 937b4e69c8 fix(cache): invalidate snapshots that lack source caveat semantics 2026-10-15 06:45:53 +08:00
morluto 1b668a527d chore: integrate current main before bug qualification 2026-10-15 06:42:51 +08:00
morluto 1e377e6965 perf(evidence): group repeated caveats with their source operations 2026-10-15 06:39:48 +08:00
morluto fb6e2b07e0 fix(ida): bound readiness requests by the remaining deadline 2026-10-15 06:34:32 +08:00
morluto c4608a2962 fix(evidence): preserve source attribution across composed workflows 2026-10-15 06:33:48 +08:00
morluto 225f26a5f8 refactor(ghidra): give function boundary scans one owner 2026-10-15 06:25:02 +08:00
morluto a004061abc fix(native): preserve provider limitations through call traces 2026-10-15 06:20:20 +08:00
morluto 01a1b3fda6 fix(ghidra): retain function boundary limitations on references 2026-10-15 06:20:20 +08:00
Ysopwnable 3d2846f237 fix(ghidra): invalidate synthetic entry reference snapshots (#3728)
fix(ghidra): exclude synthetic entry references
2026-10-15 06:14:29 +08:00
50 changed files with 1809 additions and 1231 deletions

No files matched your search

+36 -944
View File
@@ -1,959 +1,51 @@
name: CI
name: Experimental Windows readonly fixture mutation oracle
on:
push:
branches: [main]
pull_request:
branches: [main, "release/*"]
schedule:
- cron: "23 0-2,4-23 * * *"
- cron: "23 3 * * *"
workflow_dispatch:
workflow_call:
inputs:
source_sha:
description: Exact qualification source; must match the caller revision.
required: true
type: string
permissions:
contents: read
concurrency:
# Complete qualification finishes even while main receives more pushes.
# Main feedback also finishes, coalescing newer pushes to one pending run.
# Scheduled/manual main runs coalesce to one active and the latest pending;
# release and label-triggered qualification use exact-revision groups.
group: ci-${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || inputs.source_sha != '' || contains(github.event.pull_request.labels.*.name, 'ci:full')) && 'qualification' || 'feedback' }}-${{ inputs.source_sha || github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' && inputs.source_sha == '' && !contains(github.event.pull_request.labels.*.name, 'ci:full') }}
env:
HUSKY: "0"
actions: read
jobs:
changes:
name: Classify changes
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
actions: read
outputs:
tier: ${{ steps.plan.outputs.tier }}
qualification_run_id: ${{ steps.qualification.outputs.run_id }}
code: ${{ steps.plan.outputs.code }}
reverse_tools: ${{ steps.plan.outputs.reverse_tools }}
sqlite: ${{ steps.plan.outputs.sqlite }}
runtime: ${{ steps.plan.outputs.runtime }}
package: ${{ steps.plan.outputs.package }}
apple: ${{ steps.plan.outputs.apple }}
fixtures: ${{ steps.plan.outputs.fixtures }}
readiness: ${{ steps.plan.outputs.readiness }}
docs: ${{ steps.plan.outputs.docs }}
workflows: ${{ steps.plan.outputs.workflows }}
browser: ${{ steps.plan.outputs.browser }}
binary_layout: ${{ steps.plan.outputs.binary_layout }}
evm: ${{ steps.plan.outputs.evm }}
javascript_recovery: ${{ steps.plan.outputs.javascript_recovery }}
recorded_crash: ${{ steps.plan.outputs.recorded_crash }}
web_network: ${{ steps.plan.outputs.web_network }}
web_runtime: ${{ steps.plan.outputs.web_runtime }}
web_source_map: ${{ steps.plan.outputs.web_source_map }}
nativeaot: ${{ steps.plan.outputs.nativeaot }}
website: ${{ steps.plan.outputs.website }}
matrix: ${{ steps.plan.outputs.matrix }}
test_selection: ${{ steps.plan.outputs.test_selection }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
ref: ${{ inputs.source_sha || github.sha }}
fetch-depth: 0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
- name: Bind qualification to the caller revision
if: inputs.source_sha != ''
env:
SOURCE_SHA: ${{ inputs.source_sha }}
run: |
test "${SOURCE_SHA}" = "${GITHUB_SHA}"
test "${SOURCE_SHA}" = "$(git rev-parse HEAD)"
- name: Find a verified ancestor for push selection
id: baseline
if: github.event_name == 'push' && inputs.source_sha == ''
env:
GH_TOKEN: ${{ github.token }}
HEAD_SHA: ${{ github.sha }}
CI_REPOSITORY: ${{ github.repository }}
CI_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: node scripts/ci/baseline.mjs
- name: Admit scheduled full qualification
id: qualification
if: github.event_name == 'schedule' && inputs.source_sha == ''
env:
GH_TOKEN: ${{ github.token }}
HEAD_SHA: ${{ github.sha }}
CI_REPOSITORY: ${{ github.repository }}
CI_REPOSITORY_ID: ${{ github.repository_id }}
CI_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
CI_FORCE_FULL: ${{ github.event.schedule == '23 3 * * *' }}
run: node scripts/ci/qualification.mjs
- name: Select verification lanes
id: plan
env:
BASE_SHA: ${{ steps.baseline.outputs.base || github.event.pull_request.base.sha }}
BASELINE_REASON: ${{ steps.baseline.outputs.reason }}
HEAD_SHA: ${{ inputs.source_sha || github.event.pull_request.head.sha || github.sha }}
CI_FULL: ${{ inputs.source_sha != '' || contains(github.event.pull_request.labels.*.name, 'ci:full') }}
CI_SKIP_FULL: ${{ steps.qualification.outputs.run_full == 'false' }}
run: node scripts/ci/plan.mjs
static:
name: Static checks
needs: changes
if: ${{ !cancelled() && needs.changes.outputs.tier != 'reused' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Require successful change classification
env:
CHANGES_RESULT: ${{ needs.changes.result }}
run: test "${CHANGES_RESULT}" = "success"
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
- run: npm ci
- name: Restore static task cache
if: needs.changes.outputs.code == 'true'
id: static-cache
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: .turbo/cache/
key: rea-static-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc', '.npmrc', 'package-lock.json') }}-${{ github.sha }}
# Turbo validates each task's inputs after restoring this snapshot.
restore-keys: rea-static-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc', '.npmrc', 'package-lock.json') }}-
- name: Validate workflow syntax and expressions
if: needs.changes.outputs.workflows == 'true'
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 -shellcheck= -pyflakes=
- name: Record static task source and execution identity
continue-on-error: true
if: needs.changes.outputs.code == 'true'
env:
CI_PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: npm run ci:static-context
- if: needs.changes.outputs.code == 'true'
run: npm run check:ci
- if: needs.changes.outputs.code == 'true'
run: npm run verify:test-discovery
- name: Check LLDB producer retention and launch contracts
if: needs.changes.outputs.code == 'true'
run: npm run test:python
- if: needs.changes.outputs.code != 'true'
run: npm run format:check
- name: Save validated static task cache
if: >-
success() && needs.changes.outputs.code == 'true' &&
steps.npm-cache.outputs.can-save == 'true' &&
steps.static-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: .turbo/cache/
key: ${{ steps.static-cache.outputs.cache-primary-key }}
- name: Upload complete static diagnostics
if: always() && needs.changes.outputs.code == 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: static-check-reports
path: .cache/static-checks/
include-hidden-files: true
if-no-files-found: ignore
retention-days: 7
test-source:
name: Source tests
needs: changes
if: needs.changes.outputs.code == 'true' && needs.changes.outputs.tier == 'fast'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
- run: npm ci
- name: Run source tests without waiting for build
run: npm run test:fast -- --reporter=default --reporter=blob
env:
VITEST_BLOB_LABEL: source
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: ${{ !cancelled() }}
with:
name: vitest-report-source
path: .vitest/blob/*
include-hidden-files: true
if-no-files-found: error
retention-days: 7
test-shard:
name: Test shard (${{ matrix.shard }})
needs: [changes, build]
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
# Failed PRs release the remaining matrix slots; qualification retains
# every shard's diagnostics and coverage. Passing PRs run the full suite.
fail-fast: ${{ github.event_name == 'pull_request' && needs.changes.outputs.tier == 'fast' }}
matrix:
# Fast feedback spends the same six test jobs on one source group and
# five built groups; full qualification retains six complete shards.
shard: ${{ fromJSON(needs.changes.outputs.tier == 'fast' && '["1/5","2/5","3/5","4/5","5/5"]' || '["1/6","2/6","3/6","4/6","5/6","6/6"]') }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
- run: npm ci
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: rea-test-runtime
path: .
- name: Run complete portable suite with coverage
if: needs.changes.outputs.tier == 'full'
run: npm run test:ci:shard:run -- --shard=${{ matrix.shard }}
env:
REA_CI_TEST_HISTORY: .cache/ci-test-history/test-results.json
- name: Run built-dependent tests for fast feedback
if: needs.changes.outputs.tier == 'fast'
run: npm run test:built -- --reporter=default --reporter=blob --shard=${{ matrix.shard }}
env:
VITEST_BLOB_LABEL: built
REA_CI_TEST_HISTORY: .cache/ci-test-history/test-results.json
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: ${{ !cancelled() }}
with:
name: vitest-report-built-${{ strategy.job-index }}
path: .vitest/blob/*
include-hidden-files: true
if-no-files-found: error
overwrite: true
retention-days: 7
reverse-tools-quality:
name: Reverse tools quality
needs: [changes, static]
if: needs.changes.outputs.reverse_tools == 'true'
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
- run: npm ci
- name: Run reverse tools tests with coverage
run: npm run test:coverage:reverse-tools
- name: Preflight both reverse tools mutation configurations
run: |
node scripts/prepare-reverse-tools-mutation.mjs
npx --no-install stryker run stryker.config.mjs --dryRunOnly
npx --no-install stryker run stryker.semantics.config.mjs --dryRunOnly
- name: Retain available reverse tools diagnostics
if: ${{ !cancelled() }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: reverse-tools-quality-${{ needs.changes.outputs.tier }}-${{ github.run_attempt }}
path: |
coverage/reverse-tools/
reports/mutation/
if-no-files-found: warn
retention-days: 7
build:
name: Build package
needs: changes
# Reusable provider workflows consume this run's portable build artifact,
# even when a workflow-only edit selects the lane without portable tests.
if: >-
needs.changes.outputs.code == 'true' ||
needs.changes.outputs.browser == 'true' ||
needs.changes.outputs.binary_layout == 'true' ||
needs.changes.outputs.evm == 'true' ||
needs.changes.outputs.javascript_recovery == 'true' ||
needs.changes.outputs.recorded_crash == 'true' ||
needs.changes.outputs.web_network == 'true' ||
needs.changes.outputs.web_runtime == 'true' ||
needs.changes.outputs.web_source_map == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
actions: read
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
- run: npm ci
- name: Reuse portable build outputs across runs
uses: ./.github/actions/portable-build-cache
with:
save: ${{ github.event_name != 'pull_request' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
- run: npm run test:prepare
- name: Find available trusted full-suite timing history
id: history
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
CI_REPOSITORY: ${{ github.repository }}
CI_REPOSITORY_ID: ${{ github.repository_id }}
CI_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: node scripts/ci/test-history.mjs
- name: Download previous timing report
if: steps.history.outputs.run-id != ''
continue-on-error: true
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: test-timings
run-id: ${{ steps.history.outputs.run-id }}
github-token: ${{ github.token }}
path: .cache/ci-test-history
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: rea-test-runtime
path: |
dist/
skills/
.cache/mcp-tool-catalog.json
.cache/ci-test-history/test-results.json
docs/public/product-catalog.json
docs/verification/managed-conformance-*.json
include-hidden-files: true
if-no-files-found: error
retention-days: 7
# test:prepare caches only portable JS, skill, and catalog outputs.
# Native package jobs still run their build and prepack checks; Turbo
# restores matching outputs or rebuilds when their inputs differ.
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: rea-build-cache
path: .turbo/cache/
include-hidden-files: true
if-no-files-found: error
retention-days: 7
sqlite-runtime:
# Preserve all selected coverage, but admit expensive host/provider work
# only after static validation. Portable tests still provide early feedback.
needs: [changes, static, build]
if: needs.changes.outputs.sqlite == 'true'
uses: ./.github/workflows/sqlite-runtime.yml
test:
name: Portable tests
needs: [changes, test-source, test-shard]
if: ${{ !cancelled() && needs.changes.outputs.tier != 'reused' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Require successful change classification
env:
CHANGES_RESULT: ${{ needs.changes.result }}
run: test "${CHANGES_RESULT}" = "success"
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
if: needs.changes.outputs.code == 'true' && success()
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
if: needs.changes.outputs.code == 'true' && success()
with:
node-version-file: .nvmrc
- name: Cache npm package downloads
if: needs.changes.outputs.code == 'true' && success()
id: npm-cache
uses: ./.github/actions/npm-cache
- if: needs.changes.outputs.code == 'true' && success()
run: npm ci
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: >-
!cancelled() && needs.changes.outputs.code == 'true' &&
(needs.test-shard.result == 'success' || needs.test-shard.result == 'failure' ||
(needs.changes.outputs.tier == 'fast' &&
(needs.test-source.result == 'success' || needs.test-source.result == 'failure')))
with:
path: .vitest/blob
pattern: vitest-report-*
merge-multiple: true
- name: Merge coverage, JUnit, and timing reports
id: merge
if: >-
!cancelled() && needs.changes.outputs.code == 'true' &&
needs.changes.outputs.tier == 'full' &&
(needs.test-shard.result == 'success' || needs.test-shard.result == 'failure')
run: >-
npm run test:ci:merge --
--coverage.reportsDirectory=coverage
--coverage.reporter=text
--coverage.reporter=json-summary
--coverage.reporter=lcov
--reporter=default
--reporter=junit
--reporter=json
--reporter=./scripts/ci/test-timing-reporter.mjs
--outputFile.junit=test-results.xml
--outputFile.json=test-results.json
- name: Merge fast-suite JUnit and timing reports
id: merge-fast
if: >-
!cancelled() && needs.changes.outputs.code == 'true' &&
needs.changes.outputs.tier == 'fast' &&
(needs.test-shard.result == 'success' || needs.test-shard.result == 'failure' ||
needs.test-source.result == 'success' || needs.test-source.result == 'failure')
run: >-
npm exec -- vitest --merge-reports
--reporter=default
--reporter=junit
--reporter=json
--reporter=./scripts/ci/test-timing-reporter.mjs
--outputFile.junit=test-results.xml
--outputFile.json=test-results.json
- name: Summarize slowest test files
if: needs.changes.outputs.code == 'true' && !cancelled()
env:
SHARD_RESULT: ${{ needs.test-shard.result }}
SOURCE_REQUIRED: ${{ needs.changes.outputs.tier == 'fast' }}
SOURCE_RESULT: ${{ needs.test-source.result }}
MERGE_RESULT: ${{ needs.changes.outputs.tier == 'full' && steps.merge.outcome || steps.merge-fast.outcome }}
CI_VERIFY_TEST_INVENTORY: "true"
CI_TEST_SELECTION: ${{ needs.changes.outputs.test_selection }}
CI_PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: node scripts/ci/test-report.mjs
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: needs.changes.outputs.code == 'true' && !cancelled() && hashFiles('test-results.json') != ''
with:
name: test-results
path: |
test-results.xml
test-results.json
test-timings.json
coverage/
.cache/ci-test-selection/report.json
include-hidden-files: true
if-no-files-found: error
retention-days: 30
- name: Publish successful timing history
if: needs.changes.outputs.code == 'true' && needs.changes.outputs.tier == 'full' && success() && needs.test-shard.result == 'success'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: test-timings
path: .cache/ci-test-history/test-results.json
include-hidden-files: true
if-no-files-found: error
retention-days: 30
- name: Require successful test shards
if: ${{ !cancelled() }}
env:
CODE_REQUIRED: ${{ needs.changes.outputs.code }}
SHARD_RESULT: ${{ needs.test-shard.result }}
SOURCE_REQUIRED: ${{ needs.changes.outputs.tier == 'fast' }}
SOURCE_RESULT: ${{ needs.test-source.result }}
run: |
if [ "${CODE_REQUIRED}" = "true" ]; then
test "${SHARD_RESULT}" = "success" || exit 1
test "${SOURCE_REQUIRED}" != "true" || test "${SOURCE_RESULT}" = "success"
fi
conformance-fixtures:
name: Source-built conformance fixtures
needs: [changes, static]
if: needs.changes.outputs.fixtures == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Verify fixtures from a clean checkout
run: npm run verify:fixtures
windows-native-build:
name: Build bundled Windows x64 native controls
needs: [changes, static]
if: needs.changes.outputs.runtime == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
- name: Prepare cross compiler
run: |
# Slow Azure archive transfers can exhaust the job before compilation.
# Keep the same signed packages and avoid slow HTTP mirror fallback.
for source in /etc/apt/apt-mirrors.txt /etc/apt/sources.list /etc/apt/sources.list.d/ubuntu.sources; do
if [ -f "$source" ]; then
sudo sed -i 's|azure.archive.ubuntu.com|archive.ubuntu.com|g; s|http://archive.ubuntu.com|https://archive.ubuntu.com|g' "$source"
fi
done
sudo apt-get update
sudo apt-get install --no-install-recommends -y g++-mingw-w64-x86-64
- run: npm run build:windows-native
- run: npm run verify:windows-native:artifact
- run: node scripts/build-windows-native.mjs --inspection-fixture
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: windows-native-inspection-fixture
path: .cache/windows-native-inspection-fixture/
if-no-files-found: error
# The process boundary observations are independent of the addon: they
# report the caller's and child's own token, environment and breakaway
# results from a separate executable. Cross-build it in the lane that
# already has MinGW so the Windows job can observe them too.
- name: Build Windows process boundary fixture
run: |
mkdir -p .cache/windows-process-boundary
x86_64-w64-mingw32-g++ -municode -std=c++17 -O2 -static -Wall -Wextra \
-o .cache/windows-process-boundary/process-boundary.exe \
tests/fixtures/windows/processBoundary.cc -ladvapi32
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: windows-process-boundary-fixture
path: .cache/windows-process-boundary/
if-no-files-found: error
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: windows-native-controls
path: native/windows/build/
if-no-files-found: error
package-e2e:
name: Native package baseline (${{ matrix.platform }} ${{ matrix.architecture }})
needs: [changes, static, build, windows-native-build]
if: needs.changes.outputs.runtime == 'true'
verify:
runs-on: windows-latest
timeout-minutes: 15
strategy:
fail-fast: false
# The four platform jobs use independent hosted runners; keep each
# runner's existing heap and threadpool limits while avoiding a queued
# second platform wave.
max-parallel: 4
matrix: ${{ fromJSON(needs.changes.outputs.matrix) }}
runs-on: ${{ matrix.os }}
timeout-minutes: 20
env:
NODE_OPTIONS: "--max-old-space-size=1024"
UV_THREADPOOL_SIZE: "2"
matrix:
variant: [without-normal-fix, fixed]
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version-file: .nvmrc
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
- name: Verify native runtime identity
env:
EXPECTED_CI_PLATFORM: ${{ matrix.platform }}
EXPECTED_CI_ARCHITECTURE: ${{ matrix.architecture }}
run: >-
node -e "const assert = require('node:assert/strict');
assert.equal(process.platform, process.env.EXPECTED_CI_PLATFORM);
assert.equal(process.arch, process.env.EXPECTED_CI_ARCHITECTURE);
console.log(JSON.stringify({ platform: process.platform, architecture: process.arch, node: process.version }));"
- run: npm ci
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: rea-build-cache
path: .turbo/cache/
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: windows-native-controls
path: native/windows/build/
- run: npm run verify:windows-native:artifact
- name: Verify NativeAOT diagnostic applicability on this host
run: npm run test:focused -- tests/evaluation/package/nativeAotDiagnostics.test.ts
- run: npm run verify:package
env:
npm_config_cache: ${{ steps.npm-cache.outputs.path }}
REA_VERIFIED_PACKAGE_DIRECTORY: ${{ needs.changes.outputs.tier == 'full' && matrix.platform == 'linux' && matrix.architecture == 'x64' && '.cache/verified-package' || '' }}
- name: Verify real Node Inspector discovery and observation
timeout-minutes: 10
run: npm run verify:inspector
- name: Retain the exact qualified release package
if: needs.changes.outputs.tier == 'full' && matrix.platform == 'linux' && matrix.architecture == 'x64'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: verified-release-package
path: .cache/verified-package/
include-hidden-files: true
if-no-files-found: error
overwrite: true
retention-days: 7
apple-artifacts:
name: Real Apple artifact CLI/MCP E2E
needs: [changes, static, build]
if: needs.changes.outputs.apple == 'true'
runs-on: macos-15
timeout-minutes: 15
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
- run: npm ci
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: rea-build-cache
path: .turbo/cache/
- name: Verify Apple artifact formats and metadata
env:
REA_ARTIFACT_NODE_COMPILE_CACHE: ${{ runner.temp }}/rea-artifact-node-cache
run: |
npm run verify:keyed-archive
npm run verify:asset-catalog
npm run verify:macos-bundle
npm run verify:legacy-simulator
npm run verify:apple-dispatch
npm run verify:interface-builder
- name: Run native Apple and process boundary regressions
run: >-
npm run test:focused --
tests/boundary/filesystem/installScript.test.ts
tests/boundary/filesystem/nibNativeHierarchy.test.ts
tests/boundary/filesystem/interfaceBuilderBundle.test.ts
tests/boundary/cli/nativePlistCli.test.ts
tests/boundary/cli/nativePlistPrototypeKeyCli.test.ts
tests/boundary/cli/nativePlistSelectionFailureCli.test.ts
tests/boundary/cli/nativeDemangleCli.test.ts
tests/boundary/mcp/nativeDemangleMcp.test.ts
tests/boundary/providers/native/nativeSignatureTargetBinding.test.ts
tests/boundary/cli/nativeSignatureCli.test.ts
tests/boundary/mcp/nativeSignatureTargetBindingMcp.test.ts
src/process/capture/FilesystemSnapshot.test.ts
tests/process-global/filesystemSnapshotFallbackCancellation.test.ts
tests/boundary/process/darwinProcessRunTokenReader.test.ts
tests/boundary/process/directAnalysisSignals.test.ts
tests/boundary/process/processCaptureLifecycle.test.ts
tests/boundary/process/processCli.test.ts
tests/boundary/mcp/sessionMcp.test.ts
reconstruction-readiness:
name: Reconstruction readiness
needs: [changes, static]
if: needs.changes.outputs.readiness == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
- run: npm ci
- name: Run the public CLI/MCP reconstruction journey
env:
REA_READINESS_REPORT_PATH: readiness-report.json
run: |
set -o pipefail
npm run verify:readiness | tee readiness-summary.json
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: ${{ !cancelled() }}
with:
name: reconstruction-readiness
path: |
readiness-summary.json
readiness-report.json
if-no-files-found: error
retention-days: 30
windows-curated:
name: Windows capability and package lane
needs: [changes, static, build, windows-native-build]
if: needs.changes.outputs.package == 'true'
runs-on: windows-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
- run: npm ci
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: rea-build-cache
path: .turbo/cache/
- run: npm run build:cached
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
name: windows-native-controls
github-token: ${{ github.token }}
run-id: ${{ matrix.variant == 'fixed' && '38662148662' || '38660840144' }}
name: windows-creation-owner-native-fixed
path: native/windows/build/
- run: npm run verify:windows-native:artifact
- name: Record named Windows host capabilities
run: node scripts/windows-capabilities.mjs | Out-File -Encoding utf8NoBOM windows-capabilities.json
- name: Validate Windows host capability report
run: >-
node -e "const report = JSON.parse(require('node:fs').readFileSync('windows-capabilities.json', 'utf8'));
if (report.platform !== 'win32' || report.architecture !== 'x64')
throw new Error('expected a Windows x64 capability report');"
# Keep this list aligned with tests/process-global/curatedWindowsLane.test.ts,
# which fails when any path below is renamed or removed. Vitest silently
# ignores unmatched paths, so a stale entry would otherwise report green
# while skipping coverage.
#
# The matching native artifact establishes real Job Object and private
# runtime boundaries for these fixtures. Ghidra and Java are not needed;
# real engine claims remain in the separate controlled-provider workflow.
- name: Run curated Windows capability tests
run: >-
npx vitest run --coverage.enabled=false
src/process/capture/FilesystemSnapshot.test.ts
tests/process-global/filesystemSnapshotFallbackCancellation.test.ts
tests/boundary/filesystem/binaryTarget.test.ts
tests/boundary/filesystem/safeOutputTree.test.ts
tests/boundary/providers/native/artifactProvider.archiveSafety.test.ts
tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts
tests/boundary/filesystem/setupUnavailableProvider.test.ts
tests/boundary/filesystem/windowsNpxRegistration.test.ts
tests/boundary/android/workspaceGuardianRetry.test.ts
tests/acceptance/setup/homeDirectoryFallback.test.ts
tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts
tests/boundary/filesystem/webScriptManifestPaths.test.ts
tests/boundary/process/ProcessEvidence.host.test.ts
tests/boundary/process/windowsOutputCapture.test.ts
tests/acceptance/webModuleCli.test.ts
tests/acceptance/webSourceLocationCli.test.ts
tests/boundary/cli/referenceSourceImportStatus.test.ts
tests/boundary/filesystem/asarInventoryFreshness.test.ts
tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts
tests/conformance/providers/ghidra/windowsCapabilities.test.ts
src/ghidra/GhidraInstallation.test.ts
tests/boundary/filesystem/ghidraJavaProbe.test.ts
tests/boundary/providers/ghidra/ghidraTransport.test.ts
tests/boundary/providers/ghidra/ghidraTargetSnapshot.test.ts
tests/boundary/providers/ghidra/ghidraLauncher.test.ts
tests/boundary/providers/hopper/hopperClient.cleanupRetry.test.ts
src/windows/WindowsNativeLoader.test.ts
src/windows/WindowsOwnedProcess.test.ts
src/ghidra/GhidraLauncherEnvironment.test.ts
src/process/ProcessOwnership.test.ts
src/process/ProcessOwnership.part2.test.ts
src/process/ProcessOwnership.part3.test.ts
src/process/ProcessOwnership.validation.test.ts
src/application/Update.test.ts
tests/process-global/updateMaintenance.test.ts
tests/conformance/providers/ghidra/realGhidraWindowsWorkflow.test.ts
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: windows-process-boundary-fixture
path: .cache/windows-process-boundary/
# Passing the fixture adds the breakaway, caller/child token, environment
# semantics and ancestor reparse observations, which the lane skips
# entirely when it is absent.
- run: >-
npm run verify:windows-native -- .
"${{ github.workspace }}\.cache\windows-process-boundary\process-boundary.exe"
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: windows-native-inspection-fixture
path: .cache/windows-native-inspection-fixture/
- name: Verify native inspection failure ownership and recovery
run: node scripts/verify-windows-native-inspection.mjs
- name: Verify real Node Inspector discovery and observation
timeout-minutes: 10
- name: Verify permanent fixture detects missing NORMAL fallback
shell: bash
env:
NODE_OPTIONS: "--max-old-space-size=1024"
UV_THREADPOOL_SIZE: "2"
run: npm run verify:inspector
- run: npm run verify:package:windows
- name: Verify native directory identity on Node 24
run: >-
npx vitest run --coverage.enabled=false
tests/boundary/filesystem/directoryArtifactReaderOpen.test.ts
tests/boundary/filesystem/javascriptPackagePrecedence.test.ts
tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22.19.0
- name: Verify native path boundaries on Node 22
run: >-
npx vitest run --coverage.enabled=false
tests/boundary/filesystem/directoryArtifactReaderOpen.test.ts
tests/boundary/filesystem/javascriptPackagePrecedence.test.ts
tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts
tests/boundary/process/sourceMapUncHosts.test.ts
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: always()
with:
name: windows-capabilities
path: windows-capabilities.json
retention-days: 30
docs:
name: Docs Generation
needs: changes
if: needs.changes.outputs.docs == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
- name: Cache npm package downloads
id: npm-cache
uses: ./.github/actions/npm-cache
- run: npm ci
- run: npm run docs:check
- name: Generation leaves authored files unchanged
run: git diff --exit-code
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: generated-docs
path: |
docs/public/product-catalog.json
docs/verification/managed-conformance-*.json
skills/reverse-engineer-anything
if-no-files-found: error
browser:
needs: [changes, static, build]
if: needs.changes.outputs.browser == 'true'
uses: ./.github/workflows/real-browser.yml
with:
build_cache_artifact: rea-build-cache
binary_layout:
needs: [changes, static, build]
if: needs.changes.outputs.binary_layout == 'true'
uses: ./.github/workflows/real-binary-layout.yml
with:
build_cache_artifact: rea-build-cache
evm:
needs: [changes, static, build]
if: needs.changes.outputs.evm == 'true'
uses: ./.github/workflows/real-evm-interface.yml
with:
build_cache_artifact: rea-build-cache
javascript_recovery:
needs: [changes, static, build]
if: needs.changes.outputs.javascript_recovery == 'true'
uses: ./.github/workflows/real-javascript-recovery.yml
with:
build_cache_artifact: rea-build-cache
recorded_crash:
needs: [changes, static, build]
if: needs.changes.outputs.recorded_crash == 'true'
uses: ./.github/workflows/real-recorded-crash.yml
with:
build_cache_artifact: rea-build-cache
web_network:
needs: [changes, static, build]
if: needs.changes.outputs.web_network == 'true'
uses: ./.github/workflows/real-web-network-captures.yml
with:
build_cache_artifact: rea-build-cache
web_runtime:
needs: [changes, static, build]
if: needs.changes.outputs.web_runtime == 'true'
uses: ./.github/workflows/real-web-runtime.yml
with:
build_cache_artifact: rea-build-cache
web_source_map:
needs: [changes, static, build]
if: needs.changes.outputs.web_source_map == 'true'
uses: ./.github/workflows/real-web-source-map.yml
with:
build_cache_artifact: rea-build-cache
nativeaot:
needs: [changes, static]
if: needs.changes.outputs.nativeaot == 'true'
uses: ./.github/workflows/nativeaot-fixtures.yml
website:
needs: [changes, static]
if: needs.changes.outputs.website == 'true'
uses: ./.github/workflows/website-check.yml
required:
name: CI required
# Report failed dependencies, but release superseded PR concurrency slots.
if: ${{ !cancelled() }}
needs:
[
changes,
static,
build,
reverse-tools-quality,
test-shard,
test-source,
sqlite-runtime,
test,
conformance-fixtures,
windows-native-build,
package-e2e,
apple-artifacts,
reconstruction-readiness,
windows-curated,
docs,
browser,
binary_layout,
evm,
javascript_recovery,
recorded_crash,
web_network,
web_runtime,
web_source_map,
nativeaot,
website,
]
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
- name: Require every selected lane to pass
id: gate
env:
CI_JOBS: ${{ toJSON(needs) }}
CI_QUALIFICATION_DIRECTORY: .cache/ci-qualification
run: node scripts/ci/required.mjs
- name: Upload full qualification receipt artifact
if: needs.changes.outputs.tier == 'full' && success()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: ${{ steps.gate.outputs.receipt_name }}
path: ${{ steps.gate.outputs.receipt_path }}
if-no-files-found: error
overwrite: true
retention-days: 7
EXPECTED_VARIANT: ${{ matrix.variant }}
run: |
node --input-type=module <<'JS'
import assert from 'node:assert/strict';
import {spawnSync} from 'node:child_process';
const result=spawnSync(process.execPath,['scripts/verify-windows-native.mjs','.'],{encoding:'utf8',timeout:180000});
if(result.error)throw result.error;
process.stdout.write(result.stdout);
process.stderr.write(result.stderr);
if(process.env.EXPECTED_VARIANT==='fixed'){
assert.equal(result.status,0);
const observed=JSON.parse(result.stdout.trim());
assert.equal(observed.ok,true);
assert.equal(observed.controls.readonlyFileWithoutArchiveAttributeRemoved,true);
}else{
assert.notEqual(result.status,0);
assert.match(result.stderr,/Delete owned object by handle failed/);
assert.match(result.stderr,/Win32 5/);
}
console.log(JSON.stringify({ok:true,variant:process.env.EXPECTED_VARIANT,permanentVerifierStatus:result.status,baselineMissingNormalDetected:process.env.EXPECTED_VARIANT!=='fixed'}));
JS
+80 -67
View File
@@ -934,7 +934,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
String value = decompiledText(decompile(function));
JsonObject result = functionObservation(
value == null ? JsonNull.INSTANCE : GSON.toJsonTree(value),
noReturnCallLimitations(
functionBoundaryLimitations(
function,
currentProgram.getListing().getInstructions(function.getBody(), true)
)
@@ -1027,7 +1027,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
InstructionScan scan = scanInstructions(function, Integer.MAX_VALUE);
return functionObservation(
GSON.toJsonTree(renderAssembly(scan.instructions)),
noReturnCallLimitations(function, scan.instructions)
functionBoundaryLimitations(function, scan.instructions)
);
}
@@ -1046,7 +1046,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
limitations.add(
"The fast path does not invoke the decompiler or scan whole-program names and strings."
);
List<String> boundaryLimitations = noReturnCallLimitations(function, scan.instructions);
List<String> boundaryLimitations = functionBoundaryLimitations(function, scan.instructions);
for (String limitation : boundaryLimitations) limitations.add(limitation);
JsonObject result = new JsonObject();
result.add("procedure", procedureIdentity(function));
@@ -1069,7 +1069,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
result.add(canonicalAddress(item.getEntryPoint()));
}
if (callers) {
JsonObject observation = functionObservation(result, noReturnCallLimitations(function,
JsonObject observation = functionObservation(result, functionBoundaryLimitations(function,
currentProgram.getListing().getInstructions(function.getBody(), true)
));
observation.add("relationship_coverage", callRelationshipCoverage(null,
@@ -1099,7 +1099,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
result.add("classification", functionClassification(function));
return functionObservation(
result,
noReturnCallLimitations(
functionBoundaryLimitations(
function,
currentProgram.getListing().getInstructions(function.getBody(), true)
)
@@ -1131,12 +1131,22 @@ public final class ReaGhidraBridge extends HeadlessScript {
result.addProperty("direction", direction);
result.add("references", edges);
result.addProperty("reference_kinds_available", true);
JsonArray unresolvedCalls = direction.equals("outgoing")
? callBoundaryObservation(
List<String> limitations;
JsonArray unresolvedCalls = new JsonArray();
if (direction.equals("outgoing")) {
CallBoundaryObservation calls = callBoundaryObservation(
function, currentProgram.getListing().getInstructions(function.getBody(), true), null, true, references
).unresolvedCalls() : new JsonArray();
);
unresolvedCalls = calls.unresolvedCalls();
limitations = calls.limitations();
} else {
limitations = functionBoundaryLimitations(
function,
currentProgram.getListing().getInstructions(function.getBody(), true)
);
}
result.add("unresolved_calls", unresolvedCalls);
return result;
return functionObservation(result, limitations);
}
private JsonObject inspectNativeDataType(JsonObject params) throws Exception {
@@ -1337,12 +1347,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
result.add("references", references);
if (includeIncoming) {
TreeMap<String, Reference> incoming = new TreeMap<>();
ReferenceIterator incomingIterator = currentProgram.getReferenceManager().getReferencesTo(address);
while (incomingIterator.hasNext()) {
monitor.checkCancelled();
Reference reference = incomingIterator.next();
if (!reference.isEntryPointReference()) addReference(incoming, reference);
}
visitObservedReferencesTo(address, reference -> addReference(incoming, reference));
List<Reference> orderedIncoming = new ArrayList<>(incoming.values());
orderedIncoming.sort(REFERENCE_ORDER);
JsonArray incomingReferences = new JsonArray();
@@ -1394,16 +1399,8 @@ public final class ReaGhidraBridge extends HeadlessScript {
requireKeys(params, Set.of("document", "address"));
requireDocument(params);
Address address = requireAddress(params, "address");
ReferenceIterator references = currentProgram.getReferenceManager().getReferencesTo(address);
Set<Address> sources = new HashSet<>();
while (references.hasNext()) {
monitor.checkCancelled();
Reference reference = references.next();
if (reference.isEntryPointReference()) {
continue;
}
sources.add(reference.getFromAddress());
}
visitObservedReferencesTo(address, reference -> sources.add(reference.getFromAddress()));
List<Address> ordered = new ArrayList<>(sources);
ordered.sort(Address::compareTo);
JsonArray result = new JsonArray();
@@ -3431,51 +3428,27 @@ public final class ReaGhidraBridge extends HeadlessScript {
return omitted[0];
}
private List<String> noReturnCallLimitations(
private List<String> functionBoundaryLimitations(
Function function, Iterable<Instruction> instructions)
throws Exception {
List<String> limitations = new ArrayList<>();
Instruction beforePrevious = null;
Instruction previous = null;
for (Instruction instruction : instructions) {
monitor.checkCancelled();
String limitation = terminalCallLimitation(instruction);
if (limitation != null) limitations.add(limitation);
if (beforePrevious != null && previous != null) {
String seh = borlandSehHandlerLimitation(
function, beforePrevious, previous, instruction);
if (seh != null) limitations.add(seh);
}
beforePrevious = previous;
previous = instruction;
}
return limitations;
FunctionBoundaryLimitations boundary = new FunctionBoundaryLimitations(function);
for (Instruction instruction : instructions) boundary.observe(instruction);
return boundary.values();
}
private CallBoundaryObservation callBoundaryObservation(
Function function, Iterable<Instruction> instructions, Set<Function> returnedCallees,
boolean includeUnresolvedSites, List<Reference> observedReferences)
throws Exception {
List<String> limitations = new ArrayList<>();
FunctionBoundaryLimitations boundary = new FunctionBoundaryLimitations(function);
CallBoundaryReferences calls = new CallBoundaryReferences(returnedCallees, includeUnresolvedSites);
Instruction beforePrevious = null;
Instruction previous = null;
if (observedReferences == null) visitReferences(function, "outgoing", calls::observe);
else for (Reference reference : observedReferences) {
monitor.checkCancelled();
calls.observe(reference);
}
for (Instruction instruction : instructions) {
monitor.checkCancelled();
String limitation = terminalCallLimitation(instruction);
if (limitation != null) limitations.add(limitation);
if (beforePrevious != null && previous != null) {
String seh = borlandSehHandlerLimitation(
function, beforePrevious, previous, instruction);
if (seh != null) limitations.add(seh);
}
beforePrevious = previous;
previous = instruction;
boundary.observe(instruction);
if (!instruction.getFlowType().isCall()) continue;
boolean destination = false;
for (Reference reference : instruction.getReferencesFrom()) {
@@ -3489,10 +3462,38 @@ public final class ReaGhidraBridge extends HeadlessScript {
"Instruction decoder identifies a call but ReferenceManager supplies no call destination");
}
}
return new CallBoundaryObservation(List.copyOf(limitations),
return new CallBoundaryObservation(boundary.values(),
returnedCallees == null ? null : calls.unrepresented, calls.unresolvedCalls());
}
private final class FunctionBoundaryLimitations {
private final Function function;
private final List<String> limitations = new ArrayList<>();
private Instruction beforePrevious;
private Instruction previous;
private FunctionBoundaryLimitations(Function function) {
this.function = function;
}
private void observe(Instruction instruction) throws Exception {
monitor.checkCancelled();
String limitation = terminalCallLimitation(instruction);
if (limitation != null) limitations.add(limitation);
if (beforePrevious != null && previous != null) {
String seh = borlandSehHandlerLimitation(
function, beforePrevious, previous, instruction);
if (seh != null) limitations.add(seh);
}
beforePrevious = previous;
previous = instruction;
}
private List<String> values() {
return List.copyOf(limitations);
}
}
private String borlandSehHandlerLimitation(
Function function, Instruction handlerPush, Instruction framePush,
Instruction frameLink) {
@@ -3674,24 +3675,36 @@ public final class ReaGhidraBridge extends HeadlessScript {
monitor.checkCancelled();
Address address = addresses.next();
if (outgoing) {
for (Reference reference : currentProgram.getReferenceManager()
.getReferencesFrom(address)) {
monitor.checkCancelled();
if (!reference.isEntryPointReference()) visitor.accept(reference);
}
visitObservedReferencesFrom(address, visitor);
}
else {
ReferenceIterator iterator = currentProgram.getReferenceManager()
.getReferencesTo(address);
while (iterator.hasNext()) {
monitor.checkCancelled();
Reference reference = iterator.next();
if (!reference.isEntryPointReference()) visitor.accept(reference);
}
visitObservedReferencesTo(address, visitor);
}
}
}
private void visitObservedReferencesFrom(Address address, ReferenceVisitor visitor)
throws Exception {
for (Reference reference : currentProgram.getReferenceManager().getReferencesFrom(address)) {
monitor.checkCancelled();
visitObservedReference(reference, visitor);
}
}
private void visitObservedReferencesTo(Address address, ReferenceVisitor visitor)
throws Exception {
ReferenceIterator references = currentProgram.getReferenceManager().getReferencesTo(address);
while (references.hasNext()) {
monitor.checkCancelled();
visitObservedReference(references.next(), visitor);
}
}
private static void visitObservedReference(Reference reference, ReferenceVisitor visitor)
throws Exception {
if (!reference.isEntryPointReference()) visitor.accept(reference);
}
private static void addReference(TreeMap<String, Reference> observed, Reference reference) {
String key = canonicalAddress(reference.getFromAddress()) + "\u0000" +
canonicalAddress(reference.getToAddress()) + "\u0000" +
+7 -4
View File
@@ -148,8 +148,10 @@ startup; an explicit Ghidra startup timeout of 2700000 ms produces a 2820000 ms
tool timeout. The startup allowance follows an explicitly selected Ghidra or
Hopper provider, and otherwise uses the larger known Ghidra/Hopper deadline.
An explicit `REA_JEB_MCP_TIMEOUT_MS` request budget also contributes to that
maximum. Setup forwards these explicitly selected REA deadlines and the input
schema profile into the registration; unrelated process values are omitted.
maximum. For `ida` or `auto` selection, a valid IDA registration's parsed
`timeoutMs` also contributes to that maximum; its default is 300000 ms.
Setup forwards these explicitly selected REA deadlines and the input schema
profile into the registration; unrelated process values are omitted.
This headroom is a practical default, not a guarantee for every analysis
duration. Setup preserves positive timeout values already in the `rea` entry.
You can edit `startupTimeoutMs` and `toolTimeoutMs` there; doctor keeps positive
@@ -241,8 +243,9 @@ the default Ghidra allowance; an explicit Ghidra startup timeout of 2700
seconds yields 2820 seconds. Setup preserves positive caller-selected timeout
values in the REA entry, and doctor reports a non-blocking advisory when a tool
timeout is shorter than the known provider allowance. An explicitly selected
JEB request budget also contributes to the allowance. These values are
client-side deadlines, so increase them in `~/.codex/config.toml` if a
JEB request budget also contributes to the allowance. A configured IDA
registration's parsed `timeoutMs` contributes for `ida` or `auto` selection.
These values are client-side deadlines, so increase them in `~/.codex/config.toml` if a
workload needs more time. Devin honors `XDG_CONFIG_HOME` for its
`devin/mcp_config.json` on non-Windows systems.
+2 -2
View File
@@ -153,8 +153,8 @@ overview merely because its source artifact is unchanged.
Function extents and instruction completeness refer to Ghidra's analyzed body.
An inferred no-return call can terminate that body before subsequent bytes.
Pseudocode, assembly, procedure info, instruction windows, callee and caller
reads, and dossiers report observed terminal call sites in Evidence limitations,
Pseudocode, assembly, procedure info, instruction windows, incoming and outgoing
references, callee and caller reads, and dossiers report observed terminal call sites in Evidence limitations,
including resolved callee names, addresses, and FunctionManager flags when
available. On x86-32, the same views also report the exact Borland/Delphi-style
FS exception-registration pattern when its handler address falls outside the
+2 -2
View File
@@ -131,9 +131,9 @@ Use a package version that actually includes this adapter; repository main can b
{"name":"close_binary","arguments":{}}
```
Opening an MCP target starts its selected provider session; headless opening waits for auto-analysis. Set the agent's MCP request deadline long enough for the target. The registration's optional `timeoutMs` defaults to 300000 for upstream requests. An upstream SDK request that reaches this deadline returns `provider_timeout` with its operation and configured budget. A freshly opened headless worker can briefly report `status: "busy"` from `server_health`; REA re-reads the health every 100 ms until the worker answers, the caller cancels, or `timeoutMs` has elapsed since the first read. Expiry of this polling deadline while the worker is still busy also produces a retryable `provider_timeout`; no further read is started once the deadline has passed. That error names the busy tool of the last observation, or `unknown` when the worker reported none or a value that is not tool-name-shaped. Cancellation during the wait produces `cancelled`. Any other unexpected health payload, including a busy payload with an invalid shape, remains `unreadable_output`. Cancellation drains the current upstream request before cleanup; it does not force-stop GUI analysis. An open request that times out without a completed response retains the private workspace because worker inventory alone cannot prove the request has stopped. `binary_session` reports actual tool availability. Repeated doctor/setup calls are unnecessary when the registration already works.
Opening an MCP target starts its selected provider session; headless opening waits for auto-analysis. Set the agent's MCP request deadline long enough for the target. The registration's optional `timeoutMs` defaults to 300000 for upstream requests. An upstream SDK request that reaches this limit returns `provider_timeout` with its operation and actual request budget. A freshly opened headless worker can briefly report `status: "busy"` from `server_health`; REA re-reads health every 100 ms until the worker answers, the caller cancels, or `timeoutMs` has elapsed since the first read. Each health request is capped by the time remaining in that overall wait, so a stalled read cannot extend the readiness deadline by another full request timeout. Expiry while the worker is still busy produces a retryable `provider_timeout`; no further read is started after the deadline. That error names the busy tool from the last observation, or `unknown` when the worker reported none or a value that is not tool-name-shaped. If a health request itself consumes the remaining wait budget, the same last-busy timeout is returned with the stalled request's captured output. Cancellation during the wait produces `cancelled`. Any other unexpected health payload, including a busy payload with an invalid shape, remains `unreadable_output`. Cancellation drains the current upstream request before cleanup; it does not force-stop GUI analysis. An open request that times out without a completed response retains the private workspace because worker inventory alone cannot prove the request has stopped. `binary_session` reports actual tool availability. Repeated doctor/setup calls are unnecessary when the registration already works.
For guided registration, set `REA_IDA_MCP_CONFIG` before `rea setup --client <client>`. Setup includes that file reference in its reviewed plan and preserves it after approval; it does not copy upstream credentials into the agent configuration. `rea doctor --provider ida --json` validates the registration without starting IDA and explicitly leaves runtime compatibility and target binding for analysis.
For guided registration, set `REA_IDA_MCP_CONFIG` before `rea setup --client <client>`. Setup includes that file reference in its reviewed plan and preserves it after approval; it does not copy upstream credentials into the agent configuration. For `ida` or `auto` selection, Codex/Kimi setup and doctor use the effective client registration's parsed `timeoutMs` (default 300000 ms) when recommending the agent's per-tool deadline. Existing positive client deadlines remain caller choices. `rea doctor --provider ida --json` validates the registration without starting IDA and explicitly leaves runtime compatibility and target binding for analysis.
## Coverage and evidence
+35 -15
View File
@@ -60,6 +60,15 @@ napi_value wrap(napi_env env, std::unique_ptr<Resource> value) {
value.release();
return result;
}
void publishRuntimeHandle(napi_env env, napi_value observation, std::unique_ptr<Runtime>& value) {
auto handle = object(env);
check(napi_type_tag_object(env, handle, &RESOURCE_TAG));
// Complete the result before transferring cleanup ownership. Any earlier
// N-API failure leaves the unpublished runtime in its environment owner.
set(env, observation, "handle", handle);
check(napi_wrap(env, handle, value.get(), finalize, nullptr, nullptr));
value.release();
}
Resource& resource(napi_env env, napi_value value, Kind kind) {
bool tagged = false;
check(napi_check_object_type_tag(env, value, &RESOURCE_TAG, &tagged));
@@ -77,8 +86,15 @@ napi_value failureValue(napi_env env, const Failure& failure) {
std::wstring detail = message == nullptr ? L"" : message;
if (message != nullptr) LocalFree(message);
const std::wstring constraint(failure.constraint.begin(), failure.constraint.end());
auto text = string(env, constraint + (failure.path.empty() ? L"" : L" [" + failure.path + L"]") +
L" (Win32 " + std::to_wstring(failure.win32) + L"): " + detail);
std::wstring renderedMessage = constraint + (failure.path.empty() ? L"" : L" [" + failure.path + L"]") +
L" (Win32 " + std::to_wstring(failure.win32) + L"): " + detail;
if (!failure.cleanupConstraint.empty()) {
const std::wstring cleanup(failure.cleanupConstraint.begin(), failure.cleanupConstraint.end());
renderedMessage += L"; cleanup also failed: " + cleanup +
(failure.cleanupPath.empty() ? L"" : L" [" + failure.cleanupPath + L"]") +
L" (Win32 " + std::to_wstring(failure.cleanupWin32) + L")";
}
auto text = string(env, renderedMessage);
napi_value error; napi_create_error(env, nullptr, text, &error);
const char* code = failure.win32 == ERROR_FILE_NOT_FOUND || failure.win32 == ERROR_PATH_NOT_FOUND ? "ENOENT"
: failure.win32 == ERROR_ACCESS_DENIED ? "EACCES" : "ERR_REA_WINDOWS_NATIVE";
@@ -87,21 +103,24 @@ napi_value failureValue(napi_env env, const Failure& failure) {
set(env, error, "requestedPath", string(env, failure.path));
set(env, error, "win32Code", number(env, failure.win32));
set(env, error, "win32Message", string(env, detail));
if (!failure.cleanupConstraint.empty()) {
set(env, error, "cleanupConstraint", string(env, failure.cleanupConstraint));
set(env, error, "cleanupRequestedPath", string(env, failure.cleanupPath));
set(env, error, "cleanupWin32Code", number(env, failure.cleanupWin32));
}
if (failure.runtimeDirectoryUnallocated)
set(env, error, "runtimeAllocation", string(env, std::string("not-started")));
return error;
}
struct InspectionContext { std::unique_ptr<Runtime> probe; };
static void cleanupInspection(void* data) noexcept {
std::unique_ptr<InspectionContext> context(static_cast<InspectionContext*>(data));
if (!context->probe) return;
try { closeRuntime(*context->probe); }
static void cleanupRuntimeContext(void* data) noexcept {
std::unique_ptr<RuntimeContext> context(static_cast<RuntimeContext*>(data));
if (!context->pending) return;
try { closeRuntime(*context->pending); }
catch (const Failure& failure) {
// Environment teardown cannot retain an owner or return an error. Report
// terminal cleanup failure instead of claiming the directory was removed.
std::fprintf(stderr, "REA Windows probe cleanup failed (Win32 %lu) path=\"",
std::fprintf(stderr, "REA Windows runtime cleanup failed (Win32 %lu) path=\"",
static_cast<unsigned long>(failure.win32));
// Emit reversible UTF-16 escapes without allocation or CRT locale conversion.
for (wchar_t unit : failure.path) {
@@ -112,10 +131,10 @@ static void cleanupInspection(void* data) noexcept {
std::fprintf(stderr, "\"\n");
}
catch (const std::exception& failure) {
std::fprintf(stderr, "REA Windows probe cleanup failed: %s\n", failure.what());
std::fprintf(stderr, "REA Windows runtime cleanup failed: %s\n", failure.what());
}
catch (...) {
std::fprintf(stderr, "REA Windows probe cleanup failed: unknown native exception\n");
std::fprintf(stderr, "REA Windows runtime cleanup failed: unknown native exception\n");
}
}
@@ -128,12 +147,13 @@ static napi_value call(napi_env env, napi_callback_info info) {
require(count == 2, "Native call requires operation and arguments", L"", ERROR_INVALID_PARAMETER);
const auto operation = wide(env, args[0]);
const auto values = array(env, args[1]);
auto& context = *static_cast<RuntimeContext*>(data);
if (operation == L"inspect") {
require(values.empty(), "Inspect takes no arguments", L"", ERROR_INVALID_PARAMETER);
return inspect(env, static_cast<InspectionContext*>(data)->probe);
return inspect(env, context.pending);
}
if (operation.rfind(L"process_", 0) == 0) return processCall(env, operation, values);
return filesystemCall(env, operation, values);
return filesystemCall(env, operation, values, context);
} catch (const Failure& failure) {
auto error = failureValue(env, failure);
napi_throw(env, error);
@@ -145,8 +165,8 @@ static napi_value call(napi_env env, napi_callback_info info) {
}
static napi_value initialize(napi_env env, napi_value exports) {
auto context = std::make_unique<InspectionContext>();
if (napi_add_env_cleanup_hook(env, cleanupInspection, context.get()) != napi_ok) return nullptr;
auto context = std::make_unique<RuntimeContext>();
if (napi_add_env_cleanup_hook(env, cleanupRuntimeContext, context.get()) != napi_ok) return nullptr;
// The environment owns callback data before a function can expose it. Failed
// initialization still leaves a valid context for the registered cleanup hook.
auto* data = context.release();
+12 -3
View File
@@ -33,6 +33,9 @@ struct Failure : std::runtime_error {
DWORD win32;
std::wstring path;
std::string constraint;
std::string cleanupConstraint;
DWORD cleanupWin32 = ERROR_SUCCESS;
std::wstring cleanupPath;
// Affirmative proof for a failed runtime-parent admission; omission is unknown.
bool runtimeDirectoryUnallocated = false;
Failure(std::string constraint, std::wstring path = L"", DWORD code = GetLastError())
@@ -96,6 +99,9 @@ struct Runtime : Resource {
std::atomic_bool snapshotCancelled{false};
Runtime() : Resource(Kind::Runtime) {}
};
// A failed, not-yet-published runtime remains owned by its N-API environment
// until a later admission or environment teardown can retry cleanup by handle.
struct RuntimeContext { std::unique_ptr<Runtime> pending; };
struct Process : Resource {
Handle job, process, stdoutRead, stderrRead, stdinWrite;
// Present only for a job with a commit limit; receives the job's notifications.
@@ -120,21 +126,24 @@ std::wstring wide(napi_env env, napi_value value);
double numeric(napi_env env, napi_value value);
std::vector<napi_value> array(napi_env env, napi_value value);
napi_value wrap(napi_env env, std::unique_ptr<Resource> resource);
void publishRuntimeHandle(napi_env env, napi_value observation, std::unique_ptr<Runtime>& runtime);
Resource& resource(napi_env env, napi_value value, Kind kind);
std::unique_ptr<File> openFile(const std::wstring& path, DWORD access = GENERIC_READ,
bool directory = false,
DWORD finalSharing = FILE_SHARE_READ);
napi_value identity(napi_env env, HANDLE handle, const std::wstring& path);
std::unique_ptr<Runtime> createRuntime(const std::wstring& parent, const std::wstring& prefix);
void createRuntime(const std::wstring& parent, const std::wstring& prefix,
std::unique_ptr<Runtime>& owner);
void cleanupFailedRuntime(std::unique_ptr<Runtime>& owner, Failure& primary);
void closeRuntime(Runtime& runtime);
napi_value filesystemCall(napi_env env, const std::wstring& operation,
const std::vector<napi_value>& args);
const std::vector<napi_value>& args, RuntimeContext& context);
std::unique_ptr<Process> spawnProcess(const std::wstring& command, const std::wstring& commandLine,
const std::wstring& cwd, const std::vector<std::wstring>& environment,
bool protocolStdin = false, ULONGLONG memoryLimit = 0);
napi_value processCall(napi_env env, const std::wstring& operation,
const std::vector<napi_value>& args);
napi_value inspect(napi_env env, std::unique_ptr<Runtime>& probe);
napi_value inspect(napi_env env, std::unique_ptr<Runtime>& pending);
} // namespace rea
+65 -20
View File
@@ -184,6 +184,29 @@ static Handle createRelative(HANDLE parent, const std::wstring& name, DWORD acce
return result;
}
void cleanupFailedRuntime(std::unique_ptr<Runtime>& owner, Failure& primary) {
if (!owner || !owner->directory.valid()) {
owner.reset();
return;
}
try {
closeRuntime(*owner);
owner.reset();
} catch (const Failure& cleanup) {
primary.cleanupConstraint = cleanup.constraint;
primary.cleanupWin32 = cleanup.win32;
primary.cleanupPath = cleanup.path;
} catch (const std::exception& cleanup) {
primary.cleanupConstraint = cleanup.what();
primary.cleanupWin32 = ERROR_GEN_FAILURE;
primary.cleanupPath = owner->path;
} catch (...) {
primary.cleanupConstraint = "Unknown runtime cleanup failure";
primary.cleanupWin32 = ERROR_GEN_FAILURE;
primary.cleanupPath = owner->path;
}
}
// Lookup relative to an already-owned directory; path is diagnostic only.
static Handle openRelative(HANDLE parent, const std::wstring& name, DWORD access,
DWORD sharing, ULONG kind, const std::wstring& path) {
@@ -337,7 +360,11 @@ static napi_value readCutterBridgeDescriptor(napi_env env, const std::vector<nap
return result;
}
std::unique_ptr<Runtime> createRuntime(const std::wstring& parentPath, const std::wstring& prefix) {
void createRuntime(const std::wstring& parentPath, const std::wstring& prefix,
std::unique_ptr<Runtime>& owner) {
// A previous failed admission retains the original root handle. Retry that
// owner before allowing this environment to allocate another runtime.
if (owner) { closeRuntime(*owner); owner.reset(); }
std::unique_ptr<File> parent;
try { parent = openFile(parentPath, 0, true); }
catch (Failure& failure) {
@@ -349,24 +376,29 @@ std::unique_ptr<Runtime> createRuntime(const std::wstring& parentPath, const std
std::array<unsigned char, 16> random;
require(BCryptGenRandom(nullptr, random.data(), random.size(), BCRYPT_USE_SYSTEM_PREFERRED_RNG) == 0,
"Generate runtime identity failed", parentPath, ERROR_GEN_FAILURE);
Security security;
auto result = std::make_unique<Runtime>();
const auto name = prefix + hex(random.data(), random.size());
result->path = parent->path + (parent->path.back() == L'\\' ? L"" : L"\\") + name;
Security security;
result->directory = createRelative(parent->get(), name,
DELETE | FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES | READ_CONTROL,
true, security, result->path);
result->parents = std::move(parent->handles);
owner = std::move(result);
try {
verifyPrivate(result->directory.get(), result->path, true);
// The owned child pins the parent nonempty before write sharing is relaxed.
parent->handles.back() = openComponent(parent->path, 0, true, FILE_SHARE_READ | FILE_SHARE_WRITE);
result->parents = std::move(parent->handles);
} catch (...) {
FILE_DISPOSITION_INFO deletion{TRUE};
SetFileInformationByHandle(result->directory.get(), FileDispositionInfo, &deletion, sizeof(deletion));
owner->directory = createRelative(owner->parents.back().get(), name,
DELETE | FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES | FILE_WRITE_ATTRIBUTES | READ_CONTROL,
true, security, owner->path);
// FILE_CREATE proves ownership and pins the parent nonempty independently
// of admission validation. A failed validation must not retain a strict
// parent lease that prevents renaming the still-owned child before retry.
owner->parents.back() = openComponent(parent->path, 0, true, FILE_SHARE_READ | FILE_SHARE_WRITE);
verifyPrivate(owner->directory.get(), owner->path, true);
} catch (Failure& primary) {
cleanupFailedRuntime(owner, primary);
throw;
} catch (const std::exception& error) {
Failure primary(error.what(), owner->path, ERROR_GEN_FAILURE);
cleanupFailedRuntime(owner, primary);
throw primary;
}
return result;
}
static std::wstring relativePath(Runtime& root, const std::wstring& relative) {
@@ -441,6 +473,9 @@ static void dispose(HANDLE handle, const std::wstring& path) {
require(GetFileInformationByHandleEx(handle, FileBasicInfo, &info, sizeof(info)), "Read cleanup attributes failed", path);
if ((info.FileAttributes & FILE_ATTRIBUTE_READONLY) != 0) {
info.FileAttributes &= ~FILE_ATTRIBUTE_READONLY;
// Zero means "leave attributes unchanged" to FileBasicInfo. A file whose
// sole attribute was READONLY needs NORMAL to actually clear that marker.
if (info.FileAttributes == 0) info.FileAttributes = FILE_ATTRIBUTE_NORMAL;
require(SetFileInformationByHandle(handle, FileBasicInfo, &info, sizeof(info)), "Clear owned readonly attribute failed", path);
}
FILE_DISPOSITION_INFO deletion{TRUE};
@@ -657,7 +692,8 @@ static napi_value snapshot(napi_env env, napi_value rootValue, Runtime& root,
return promise;
}
napi_value filesystemCall(napi_env env, const std::wstring& operation, const std::vector<napi_value>& args) {
napi_value filesystemCall(napi_env env, const std::wstring& operation,
const std::vector<napi_value>& args, RuntimeContext& context) {
if (operation == L"cutter_bridge_verify_directory") return verifyCutterBridgeDirectory(env, args);
if (operation == L"cutter_bridge_read_descriptor") return readCutterBridgeDescriptor(env, args);
const auto expected = operation == L"open" || operation == L"close" || operation == L"runtime_close" || operation == L"runtime_snapshot_cancel" ? 1
@@ -691,12 +727,21 @@ napi_value filesystemCall(napi_env env, const std::wstring& operation, const std
return result;
}
if (operation == L"runtime_create") {
auto root = createRuntime(wide(env, args[0]), wide(env, args[1]));
auto result = identity(env, root->directory.get(), root->path);
set(env, result, "path", string(env, root->path));
set(env, result, "privateDacl", boolean(env, true));
set(env, result, "handle", wrap(env, std::move(root)));
return result;
createRuntime(wide(env, args[0]), wide(env, args[1]), context.pending);
try {
auto result = identity(env, context.pending->directory.get(), context.pending->path);
set(env, result, "path", string(env, context.pending->path));
set(env, result, "privateDacl", boolean(env, true));
publishRuntimeHandle(env, result, context.pending);
return result;
} catch (Failure& primary) {
cleanupFailedRuntime(context.pending, primary);
throw;
} catch (const std::exception& error) {
Failure primary(error.what(), context.pending ? context.pending->path : L"", ERROR_GEN_FAILURE);
cleanupFailedRuntime(context.pending, primary);
throw primary;
}
}
auto& root = static_cast<Runtime&>(resource(env, args[0], Kind::Runtime));
require(!root.closing || operation == L"runtime_close", "Runtime cleanup is pending", root.path, ERROR_BUSY);
+16 -6
View File
@@ -454,17 +454,27 @@ napi_value processCall(napi_env env, const std::wstring& operation, const std::v
throw Failure("Unknown native process operation", L"", ERROR_INVALID_PARAMETER);
}
napi_value inspect(napi_env env, std::unique_ptr<Runtime>& probe) {
napi_value inspect(napi_env env, std::unique_ptr<Runtime>& pending) {
// Settle the previous owner before admitting another probe. Failed cleanup
// retains its handles, including when identity construction itself failed.
if (probe) { closeRuntime(*probe); probe.reset(); }
if (pending) { closeRuntime(*pending); pending.reset(); }
wchar_t temp[32768];
const auto length = GetTempPathW(32768, temp);
require(length > 0 && length < 32768, "Read temporary directory coordinate failed");
probe = createRuntime(std::wstring(temp, length), L"rea-native-probe-");
auto filesystem = identity(env, probe->directory.get(), probe->path);
closeRuntime(*probe);
probe.reset();
createRuntime(std::wstring(temp, length), L"rea-native-probe-", pending);
napi_value filesystem;
try {
filesystem = identity(env, pending->directory.get(), pending->path);
} catch (Failure& primary) {
cleanupFailedRuntime(pending, primary);
throw;
} catch (const std::exception& error) {
Failure primary(error.what(), pending ? pending->path : L"", ERROR_GEN_FAILURE);
cleanupFailedRuntime(pending, primary);
throw primary;
}
closeRuntime(*pending);
pending.reset();
auto job = createJob();
Attributes attributes(1);
HANDLE raw = job.get();
+51 -1
View File
@@ -75,7 +75,57 @@ for (const architecture of ["x86_64", "x86"]) {
: [0xe8, 0x0b, 0, 0, 0, 0xe8, 0x16, 0, 0, 0, 0x31, 0xc0, 0xc3],
0x200,
);
bytes.set([0xb8, 0x2a, 0, 0, 0, 0xc3], 0x210);
bytes.set(
x86 && !dll
? [0xb8, 0x2a, 0, 0, 0, 0xe8, 0x66, 0, 0, 0, 0xc3]
: [0xb8, 0x2a, 0, 0, 0, 0xc3],
0x210,
);
if (x86 && !dll) {
// A statically analyzed Borland-style SEH registration whose handler
// entry is referenced but is not part of the registering function body.
bytes.set(
[
0x55, // PUSH EBP
0x89,
0xe5, // MOV EBP, ESP
0x68,
0xc0,
0x10,
0x40,
0x00, // PUSH 0x4010c0
0x64,
0xff,
0x35,
0,
0,
0,
0, // PUSH DWORD PTR FS:[0]
0x64,
0x89,
0x25,
0,
0,
0,
0, // MOV DWORD PTR FS:[0], ESP
0x64,
0x8f,
0x05,
0,
0,
0,
0, // POP DWORD PTR FS:[0]
0x83,
0xc4,
0x04, // ADD ESP, 4
0x5d, // POP EBP
0xc3, // RET
],
0x280,
);
bytes.set([0xc3], 0x2c0);
}
if (!dll) {
const dataSection = sectionTable + 40;
+84
View File
@@ -0,0 +1,84 @@
import assert from "node:assert/strict";
const limitationPattern = /Borland\/Delphi-style SEH frame registration/u;
/** Verify Ghidra reports, and does not absorb, the fixture's handler region. */
export async function verifyGhidraBorlandSeh(functionObservation, x86) {
assert.equal(x86, true, "The SEH control exists only in the x86 PE fixture");
const caller = "0x401015";
const procedure = "0x401080";
const handler = "0x4010c0";
const ordinary = await functionObservation("procedure_info", {
procedure: "0x401010",
});
assert.ok(
!ordinary.limitations.some((item) => limitationPattern.test(item)),
"The ordinary return-42 control unexpectedly reports an SEH limitation",
);
const info = await functionObservation("procedure_info", { procedure });
assert.ok(info.limitations.some((item) => limitationPattern.test(item)));
const outgoing = await functionObservation("procedure_references", {
procedure,
direction: "outgoing",
});
assert.equal(outgoing.value.direction, "outgoing");
assert.ok(outgoing.limitations.some((item) => limitationPattern.test(item)));
assert.ok(
outgoing.value.references.some(
(reference) =>
reference.source_address === "0x401083" &&
reference.target_address === handler &&
reference.kind.available &&
reference.kind.data &&
!reference.kind.call,
),
"The pushed handler address must remain an observed data reference, without inventing a call",
);
const incoming = await functionObservation("procedure_references", {
procedure,
direction: "incoming",
});
assert.equal(incoming.value.direction, "incoming");
assert.ok(incoming.limitations.some((item) => limitationPattern.test(item)));
assert.ok(
incoming.value.references.some(
(reference) =>
reference.source_address === caller &&
reference.target_address === procedure,
),
"The caller-to-registration procedure reference was not observed",
);
const dossier = await functionObservation("analyze_function", {
procedure,
});
assert.ok(dossier.limitations.some((item) => limitationPattern.test(item)));
assert.ok(
dossier.value.limitations.some((item) => limitationPattern.test(item)),
"The normalized function dossier lost the SEH limitation",
);
assert.equal(dossier.value.procedure.body.available, true);
assert.ok(dossier.value.procedure.body.ranges.length > 0);
assert.ok(
!dossier.value.procedure.body.ranges.some(
(range) =>
BigInt(range.start) <= BigInt(handler) &&
BigInt(range.end) >= BigInt(handler),
),
"The handler was incorrectly claimed as part of the procedure body",
);
return {
limitation: true,
ordinary_control: true,
procedure_references_incoming_and_outgoing: true,
handler_data_reference: true,
caller_reference: true,
dossier_limitation: true,
handler_body_not_claimed: true,
};
}
+12 -1
View File
@@ -29,6 +29,7 @@ import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
import { resolveGhidraVerifierTimeouts } from "./lib/ghidra-verifier-timeouts.mjs";
import { verifyGhidraCompactReferences } from "./lib/real-ghidra-compact-references.mjs";
import { verifyGhidraBorlandSeh } from "./lib/real-ghidra-borland-seh.mjs";
import { verifyGhidraFileOffsets } from "./lib/real-ghidra-file-offsets.mjs";
import { verifyWindowsGhidraByteSearch } from "./lib/ghidra-windows-byte-search.mjs";
@@ -273,6 +274,10 @@ try {
functionOperation,
architecture === "x86",
);
const borlandSeh =
architecture === "x86" && !dllFixture
? await verifyGhidraBorlandSeh(functionObservationOperation, true)
: null;
report = {
ok: true,
provider: { id: "ghidra", version: SUPPORTED_GHIDRA_VERSION },
@@ -289,6 +294,7 @@ try {
byte_search: byteSearch,
cleanup: "complete",
compact_references: compactReferences,
borland_seh: borlandSeh,
native_controls: {
job_object: true,
protected_dacl: true,
@@ -338,6 +344,11 @@ async function inventory(operation, parameters) {
}
async function functionOperation(operation, parameters) {
const observation = await functionObservationOperation(operation, parameters);
return observation.value;
}
async function functionObservationOperation(operation, parameters) {
const input = parseGhidraFunctionInput(operation, parameters);
if (!input.ok) throw input.error;
const called = await client.callTool(operation, input.value);
@@ -345,7 +356,7 @@ async function functionOperation(operation, parameters) {
const result = parseGhidraFunctionResult(operation, called.value);
if (!result.ok) throw result.error;
observed.add(operation);
return result.value.value;
return result.value;
}
function assertSession(session, targetSha256, profileDigest) {
@@ -0,0 +1,111 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import {
access,
mkdir,
mkdtemp,
readFile,
readdir,
rename,
rm,
stat,
writeFile,
} from "node:fs/promises";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
if (process.platform !== "win32")
throw new Error("Requires actual Windows NTFS");
const [rootArgument, artifactArgument, expected] = process.argv.slice(2);
assert.ok(rootArgument && artifactArgument);
assert.ok(expected === "expect-leak" || expected === "retain-owner");
const root = resolve(rootArgument);
const artifact = resolve(artifactArgument);
const manifest = JSON.parse(
await readFile(join(artifact, "manifest.json"), "utf8"),
);
const image = await readFile(join(artifact, manifest.artifact));
assert.equal(
createHash("sha256").update(image).digest("hex"),
manifest.artifactSha256,
);
assert.equal(manifest.abiVersion, 1);
assert.equal(manifest.nodeApiVersion, 8);
const native = createRequire(join(root, "package.json"))(
join(artifact, manifest.artifact),
);
const workspace = await mkdtemp(join(tmpdir(), "rea-failed-creation-probe-"));
process.env.REA_NATIVE_CREATION_FAILURE = "1";
let retry;
const failures = [];
try {
let failure;
try {
native.call("runtime_create", [workspace, "owned-"]);
} catch (cause) {
failure = cause;
}
assert.equal(failure?.constraint, "Read private DACL by handle failed");
assert.equal(failure?.win32Code, 5);
assert.notEqual(failure.runtimeAllocation, "not-started");
if (expected === "retain-owner") {
assert.equal(
failure.cleanupConstraint,
"Delete owned object by handle failed",
);
assert.equal(failure.cleanupWin32Code, 5);
} else assert.equal(failure.cleanupConstraint, undefined);
const [name, ...extra] = await readdir(workspace);
assert.ok(name && name.startsWith("owned-"));
assert.equal(extra.length, 0);
const allocated = join(workspace, name);
const identity = await stat(allocated, { bigint: true });
assert.equal(failure.requestedPath, allocated);
const moved = allocated + "-moved";
await writeFile(join(allocated, "owned-child.txt"), "owned child");
await rename(allocated, moved);
await mkdir(allocated);
const replacement = await stat(allocated, { bigint: true });
assert.notEqual(replacement.ino, identity.ino);
const sentinel = join(allocated, "unrelated.txt");
await writeFile(sentinel, "preserve replacement");
retry = native.call("runtime_create", [workspace, "retry-"]);
native.call("runtime_close", [retry.handle]);
retry = undefined;
if (expected === "expect-leak") {
assert.equal((await stat(moved, { bigint: true })).ino, identity.ino);
assert.equal(
await readFile(join(moved, "owned-child.txt"), "utf8"),
"owned child",
);
} else await assert.rejects(access(moved), { code: "ENOENT" });
assert.equal((await stat(allocated, { bigint: true })).ino, replacement.ino);
assert.equal(await readFile(sentinel, "utf8"), "preserve replacement");
console.log(
JSON.stringify({
ok: true,
expected,
postAllocationValidationDenied: true,
rollbackDispositionDenied: true,
baselineLostOwner: expected === "expect-leak",
renamedOwnerRemovedOnRetry: expected === "retain-owner",
unrelatedReplacementPreserved: true,
fixtureArtifactSha256: manifest.artifactSha256,
originalHostDeleteDenialReproduced: false,
}),
);
} catch (failure) {
failures.push(failure);
console.error(failure);
}
try {
if (retry) native.call("runtime_close", [retry.handle]);
native.call("inspect", []);
await rm(workspace, { recursive: true, force: true });
} catch (cleanupFailure) {
failures.push(cleanupFailure);
}
if (failures.length !== 0)
throw new AggregateError(failures, "Creation ownership probe failed");
+38 -9
View File
@@ -55,14 +55,29 @@ try {
join(workspace, "dist", "windows", "WindowsNativeLoader.js"),
).href;
const source = `import assert from 'node:assert/strict';
import {createRequire} from 'node:module';
import {existsSync,mkdirSync,readFileSync,readdirSync,renameSync,statSync,writeFileSync} from 'node:fs';
import {join} from 'node:path';
import {systemWindowsNativeAuthority} from ${JSON.stringify(loader)};
const temp=process.env.TEMP;
const first=systemWindowsNativeAuthority();
assert.equal(first.available,false);
assert.equal(first.retryable,true);
assert.match(first.reason,/Delete owned object by handle failed.*Win32 5/s);
const mode=process.argv[2];
const creation=mode.startsWith('create-');
const native=creation?createRequire(import.meta.url)(${JSON.stringify(join(workspace, "native", "windows", "build", "rea-windows-x64.node"))}):null;
if(creation){
let failure;
try{native.call('runtime_create',[temp,'rea-native-created-'])}catch(error){failure=error}
assert.ok(failure instanceof Error,'runtime_create unexpectedly passed injected post-create validation failure');
assert.equal(failure.constraint,'Read private DACL by handle failed');
assert.equal(failure.win32Code,5);
assert.equal(failure.cleanupConstraint,'Delete owned object by handle failed');
assert.equal(failure.cleanupWin32Code,5);
assert.notEqual(failure.runtimeAllocation,'not-started');
} else {
const first=systemWindowsNativeAuthority();
assert.equal(first.available,false);
assert.equal(first.retryable,true);
assert.match(first.reason,/Delete owned object by handle failed.*Win32 5/s);
}
const [name,...extra]=readdirSync(temp);assert.ok(name);assert.equal(extra.length,0);
const path=join(temp,name);const identity=statSync(path,{bigint:true});
// Delete sharing permits rename; ownership follows the retained NTFS handle.
@@ -73,26 +88,40 @@ assert.equal(moved.dev,identity.dev);assert.equal(moved.ino,identity.ino);
mkdirSync(path);const replacement=statSync(path,{bigint:true});
assert.notEqual(replacement.ino,identity.ino);
const sentinel=join(path,'unrelated.txt');writeFileSync(sentinel,'unrelated replacement');
if(process.argv[2]!=='teardown'){
if(mode==='retry'){
const second=systemWindowsNativeAuthority();assert.equal(second.available,true);
assert.equal(existsSync(renamed),false);
assert.deepEqual(readdirSync(temp),[name]);
assert.equal(readFileSync(sentinel,'utf8'),'unrelated replacement');
assert.equal(statSync(path,{bigint:true}).ino,replacement.ino);
assert.equal(systemWindowsNativeAuthority(),second);
} else if(mode==='create-retry') {
const second=native.call('runtime_create',[temp,'rea-native-created-retry-']);
assert.equal(existsSync(renamed),false);
assert.equal(readFileSync(sentinel,'utf8'),'unrelated replacement');
native.call('runtime_close',[second.handle]);
assert.equal(existsSync(second.path),false);
assert.equal(statSync(path,{bigint:true}).ino,replacement.ino);
}
console.log(JSON.stringify({mode:process.argv[2],firstFailed:true,retainedOwner:true,name,renamed,device:identity.dev.toString(),inode:identity.ino.toString(),replacementInode:replacement.ino.toString()}));
console.log(JSON.stringify({mode,firstFailed:true,retainedOwner:true,name,renamed,device:identity.dev.toString(),inode:identity.ino.toString(),replacementInode:replacement.ino.toString()}));
`;
const fixture = join(workspace, "probe.mjs");
await writeFile(fixture, source);
for (const mode of ["retry", "teardown"]) {
for (const mode of ["retry", "teardown", "create-retry", "create-teardown"]) {
const temp = join(workspace, mode);
await mkdir(temp);
const child = spawnSync(process.execPath, [fixture, mode], {
encoding: "utf8",
timeout: 20_000,
windowsHide: true,
env: { ...process.env, TEMP: temp, TMP: temp },
env: {
...process.env,
TEMP: temp,
TMP: temp,
...(mode.startsWith("create-")
? { REA_NATIVE_CREATION_FAILURE: "1" }
: {}),
},
});
if (child.error !== undefined) throw child.error;
assert.equal(child.status, 0, child.stderr);
@@ -119,7 +148,7 @@ console.log(JSON.stringify({mode:process.argv[2],firstFailed:true,retainedOwner:
);
}
process.stdout.write(
`${JSON.stringify({ ok: true, controlledDispositionFailure: true, retainedOwner: true, renamedOwnerCleansChildren: true, unrelatedReplacementPreserved: true, retryRecovers: true, successfulAdmissionCached: true, environmentTeardownCleansProbe: true, issue1701HostCauseReproduced: false })}\n`,
`${JSON.stringify({ ok: true, inspectionPostCreateValidationFailure: true, runtimeCreatePostCreateValidationFailure: true, controlledRollbackDispositionFailure: true, retainedOwner: true, renamedOwnerCleansChildren: true, unrelatedReplacementPreserved: true, retryRecoversByInspectionAndCreation: true, successfulAdmissionCached: true, environmentTeardownCleansProbeAndCreation: true, issue1701HostCauseReproduced: false })}\n`,
);
} finally {
await rm(workspace, { recursive: true, force: true });
+58 -11
View File
@@ -48,6 +48,13 @@ const { PrivateRuntimeRootUnavailableError } = await import(
)
);
const workspace = await mkdtemp(join(tmpdir(), "rea-native-conformance-"));
// A pwsh caller can supply module paths incompatible with Windows PowerShell.
// Each independent observer must discover its own module path.
const windowsPowerShellEnvironment = Object.fromEntries(
Object.entries(process.env).filter(
([name]) => name.toLowerCase() !== "psmodulepath",
),
);
const environment = Object.entries(process.env)
.filter(([, value]) => value !== undefined)
.map(([key, value]) => `${key}=${value}`);
@@ -309,6 +316,7 @@ process.stdout.write(JSON.stringify({executable:basename(process.execPath),abiVe
]);
const sentinel = join(sourceDirectory, "sentinel");
await writeFile(sentinel, "preserve");
const runtimeVerificationErrors = [];
try {
const acl = JSON.parse(
execFileSync(
@@ -317,18 +325,13 @@ process.stdout.write(JSON.stringify({executable:basename(process.execPath),abiVe
"-NoProfile",
"-NonInteractive",
"-Command",
"$a=Get-Acl -LiteralPath $env:REA_TEST_PRIVATE_RUNTIME_PATH; $u=[Security.Principal.WindowsIdentity]::GetCurrent().User; @{protected=$a.AreAccessRulesProtected; ownerMatchesUser=($a.GetOwner([Security.Principal.SecurityIdentifier]).Value -eq $u.Value); trusteesOnlyUserAndSystem=(@($a.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier]) | Where-Object {$_.IdentityReference.Value -ne $u.Value -and $_.IdentityReference.Value -ne 'S-1-5-18'}).Count -eq 0); count=@($a.Access).Count} | ConvertTo-Json -Compress",
"$ErrorActionPreference='Stop'; $p=$env:REA_TEST_PRIVATE_RUNTIME_PATH; $a=Get-Acl -LiteralPath $p; $u=[Security.Principal.WindowsIdentity]::GetCurrent().User; [IO.File]::SetAttributes($p,[IO.FileAttributes]([int][IO.File]::GetAttributes($p) -bor 1)); @{protected=$a.AreAccessRulesProtected; ownerMatchesUser=($a.GetOwner([Security.Principal.SecurityIdentifier]).Value -eq $u.Value); trusteesOnlyUserAndSystem=(@($a.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier]) | Where-Object {$_.IdentityReference.Value -ne $u.Value -and $_.IdentityReference.Value -ne 'S-1-5-18'}).Count -eq 0); count=@($a.Access).Count; rootReadonly=(([int][IO.File]::GetAttributes($p) -band 1) -ne 0)} | ConvertTo-Json -Compress",
],
{
encoding: "utf8",
// A pwsh caller can supply module paths incompatible with the
// Windows PowerShell observer. Let that observer build its own path.
timeout: 15_000,
env: {
...Object.fromEntries(
Object.entries(process.env).filter(
([name]) => name.toLowerCase() !== "psmodulepath",
),
),
...windowsPowerShellEnvironment,
REA_TEST_PRIVATE_RUNTIME_PATH: runtime.path,
},
},
@@ -339,6 +342,7 @@ process.stdout.write(JSON.stringify({executable:basename(process.execPath),abiVe
ownerMatchesUser: true,
trusteesOnlyUserAndSystem: true,
count: 2,
rootReadonly: true,
});
native.call("runtime_mkdir", [runtime.handle, "inputs"]);
native.call("runtime_write", [
@@ -484,7 +488,10 @@ process.stdout.write(JSON.stringify({executable:basename(process.execPath),abiVe
{
encoding: "utf8",
timeout: 15_000,
env: { ...process.env, REA_SNAPSHOT_READER_PATH: snapshotPath },
env: {
...windowsPowerShellEnvironment,
REA_SNAPSHOT_READER_PATH: snapshotPath,
},
},
).trim();
assert.equal(readerDigest, readbackSnapshot.sha256);
@@ -574,12 +581,52 @@ process.stdout.write(JSON.stringify({executable:basename(process.execPath),abiVe
asynchronousSnapshot: true,
snapshotCancellation: true,
};
} finally {
native.call("runtime_close", [runtime.handle]);
const readonlyChildren = JSON.parse(
execFileSync(
"powershell.exe",
[
"-NoProfile",
"-NonInteractive",
"-Command",
"$ErrorActionPreference='Stop'; $items=ConvertFrom-Json -InputObject $env:REA_TEST_READONLY_CHILDREN; @($items | ForEach-Object { $current=[int][IO.File]::GetAttributes($_); $next=if(($current -band 16) -ne 0){$current -bor 1}else{1}; [IO.File]::SetAttributes($_,[IO.FileAttributes]$next); $a=[int][IO.File]::GetAttributes($_); @{readonly=(($a -band 1) -ne 0); directory=(($a -band 16) -ne 0); onlyReadonly=($a -eq 1)} }) | ConvertTo-Json -Compress",
],
{
encoding: "utf8",
timeout: 15_000,
env: {
...windowsPowerShellEnvironment,
REA_TEST_READONLY_CHILDREN: JSON.stringify([
join(runtime.path, "inputs"),
join(runtime.path, "inputs", "target.bin"),
]),
},
},
),
);
assert.deepEqual(readonlyChildren, [
{ readonly: true, directory: true, onlyReadonly: false },
{ readonly: true, directory: false, onlyReadonly: true },
]);
} catch (failure) {
runtimeVerificationErrors.push(failure);
}
try {
native.call("runtime_close", [runtime.handle]);
} catch (cleanupFailure) {
runtimeVerificationErrors.push(cleanupFailure);
}
if (runtimeVerificationErrors.length === 1)
throw runtimeVerificationErrors[0];
if (runtimeVerificationErrors.length > 1)
throw new AggregateError(
runtimeVerificationErrors,
"Native runtime verification and cleanup failed",
);
assert.equal(await exists(runtime.path), false);
assert.equal(await readFile(sentinel, "utf8"), "preserve");
report.controls.cleanupDoesNotTraverseJunctions = true;
report.controls.readonlyRootAndChildrenRemoved = true;
report.controls.readonlyFileWithoutArchiveAttributeRemoved = true;
const fixture = join(workspace, "tree.mjs");
await writeFile(
fixture,
@@ -0,0 +1,175 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import {
access,
mkdir,
mkdtemp,
readFile,
rename,
rm,
stat,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
if (process.platform !== "win32")
throw new Error("Requires actual Windows NTFS");
const [rootArgument, expected] = process.argv.slice(2);
assert.ok(rootArgument);
assert.ok(expected === "deny-clear" || expected === "allow");
const root = resolve(rootArgument);
const { requireWindowsNativeAuthority } = await import(
pathToFileURL(join(root, "dist/windows/WindowsNativeLoader.js"))
);
const native = requireWindowsNativeAuthority();
const workspace = await mkdtemp(join(tmpdir(), "rea-readonly-root-probe-"));
const roots = [];
const attributes = (path, readonly) => {
const script = `$ErrorActionPreference='Stop';
$path=$env:REA_OWNED_ATTRIBUTE_PROBE;
$current=[int][IO.File]::GetAttributes($path);
$next=if($env:REA_ATTRIBUTE_PROBE_MODE -eq 'only'){1}elseif($env:REA_ATTRIBUTE_PROBE_MODE -eq 'set'){ $current -bor 1 }else{ $current -band (-bnot 1) };
[IO.File]::SetAttributes($path,[IO.FileAttributes]$next);
$observed=[int][IO.File]::GetAttributes($path);
@{readonly=(($observed -band 1) -ne 0); attributes=$observed} | ConvertTo-Json -Compress`;
const result = JSON.parse(
execFileSync(
"powershell.exe",
["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script],
{
encoding: "utf8",
timeout: 10000,
windowsHide: true,
env: {
...process.env,
REA_OWNED_ATTRIBUTE_PROBE: path,
REA_ATTRIBUTE_PROBE_MODE: readonly === "only" ? "only" : readonly ? "set" : "clear",
},
},
),
);
assert.equal(result.readonly, Boolean(readonly));
return result;
};
const create = () => {
const runtime = native.call("runtime_create", [workspace, "owned-"]);
const owned = { runtime, currentPath: runtime.path, closed: false };
roots.push(owned);
return owned;
};
const close = (owned) => {
native.call("runtime_close", [owned.runtime.handle]);
owned.closed = true;
};
try {
const normal = create();
close(normal);
await assert.rejects(access(normal.currentPath), { code: "ENOENT" });
const owned = create();
const original = owned.currentPath;
const originalIdentity = await stat(original, { bigint: true });
const marked = attributes(original, true);
assert.notEqual(marked.attributes & 16, 0, "Probe must be a directory");
const moved = original + "-moved";
await rename(original, moved);
owned.currentPath = moved;
await mkdir(original);
const replacementIdentity = await stat(original, { bigint: true });
assert.notEqual(replacementIdentity.ino, originalIdentity.ino);
const sentinel = join(original, "unrelated.txt");
await writeFile(sentinel, "replacement must survive");
let failure;
try {
close(owned);
} catch (cause) {
failure = cause;
}
if (expected === "deny-clear") {
assert.equal(failure?.win32Code, 5);
assert.equal(failure?.constraint, "Clear owned readonly attribute failed");
assert.equal(
(await stat(moved, { bigint: true })).ino,
originalIdentity.ino,
);
attributes(moved, false);
close(owned);
} else assert.equal(failure, undefined, failure?.message);
await assert.rejects(access(moved), { code: "ENOENT" });
assert.equal(
(await stat(original, { bigint: true })).ino,
replacementIdentity.ino,
);
assert.equal(await readFile(sentinel, "utf8"), "replacement must survive");
const stationary = create();
attributes(stationary.currentPath, true);
if (expected === "deny-clear") {
assert.throws(
() => close(stationary),
(error) =>
error.win32Code === 5 &&
error.constraint === "Clear owned readonly attribute failed",
);
attributes(stationary.currentPath, false);
}
close(stationary);
await assert.rejects(access(stationary.currentPath), { code: "ENOENT" });
const children = create();
native.call("runtime_mkdir", [children.runtime.handle, "nested"]);
native.call("runtime_write", [
children.runtime.handle,
"nested/readonly.txt",
Buffer.from("owned readonly file"),
]);
attributes(children.currentPath, true);
attributes(join(children.currentPath, "nested"), true);
attributes(join(children.currentPath, "nested", "readonly.txt"), true);
if (expected === "deny-clear") {
assert.throws(
() => close(children),
(error) =>
error.win32Code === 5 &&
error.constraint === "Clear owned readonly attribute failed",
);
attributes(children.currentPath, false);
}
close(children);
await assert.rejects(access(children.currentPath), { code: "ENOENT" });
const onlyReadonly = create();
native.call("runtime_write", [onlyReadonly.runtime.handle, "only-readonly.txt", Buffer.from("owned file")]);
const onlyReadonlyPath = join(onlyReadonly.currentPath, "only-readonly.txt");
assert.equal(attributes(onlyReadonlyPath, "only").attributes, 1);
if (expected === "deny-clear") {
assert.throws(() => close(onlyReadonly), error => error.win32Code === 5 && error.constraint === "Delete owned object by handle failed");
attributes(onlyReadonlyPath, false);
}
close(onlyReadonly);
await assert.rejects(access(onlyReadonly.currentPath), { code: "ENOENT" });
console.log(
JSON.stringify({
ok: true,
expected,
rootReadonlyConfirmed: true,
stationaryRootRemoved: true,
populatedReadonlyRootRemoved: true,
renamedOwnedRootRemoved: true,
unrelatedReplacementPreserved: true,
readonlyChildFileAndDirectoryRemoved: true,
readonlyFileWithoutOtherAttributesRemoved: true,
baselineConstraint: failure?.constraint ?? null,
baselineWin32: failure?.win32Code ?? null,
originalIssueDeleteDenialReproduced: false,
}),
);
} finally {
for (const owned of roots) {
if (owned.closed) continue;
attributes(owned.currentPath, false);
close(owned);
}
await rm(workspace, { recursive: true, force: true });
}
@@ -14,6 +14,12 @@ import { PRODUCT_IDENTITY } from "../identity.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import { dshDocument } from "./DshCordisPatch.js";
import type { McpToolTimeoutInputs } from "../mcpStartupPolicy.js";
import type { AnalysisProviderSelector } from "../contracts/providerSelection.js";
export type ProviderRequestBudgetResolver = (
analysisProvider: AnalysisProviderSelector,
environment: Readonly<Record<string, string>>,
) => Result<number | undefined, ConfigurationError>;
export interface ClientRegistrationEnvironmentDetails {
readonly values: Readonly<Record<string, string>>;
@@ -41,6 +47,7 @@ export const clientRegistrationEnvironmentDetails = (
parsed: ClientConfigurationDocument | undefined,
overrides: Readonly<Record<string, string>> = {},
validation: "fragment" | "effective" = "effective",
resolveProviderRequestBudget?: ProviderRequestBudgetResolver,
): Result<ClientRegistrationEnvironmentDetails, ConfigurationError> => {
if (parsed?.dialect === "dsh") {
const environment = dshDocument(parsed.document).environment(
@@ -48,7 +55,11 @@ export const clientRegistrationEnvironmentDetails = (
validation,
);
if (!environment.ok) return environment;
return validatedDetails(environment.value, validation);
return validatedDetails(
environment.value,
validation,
resolveProviderRequestBudget,
);
}
const key =
parsed?.dialect === "opencode" || parsed?.dialect === "opencode_v2"
@@ -78,18 +89,29 @@ export const clientRegistrationEnvironmentDetails = (
// Validate known settings with the runtime's parser, but retain the original
// string overrides, including unknown client-specific keys and no defaults.
const environment = { ...existing.data, ...overrides };
return validatedDetails(environment, validation);
return validatedDetails(
environment,
validation,
resolveProviderRequestBudget,
);
};
const validatedDetails = (
environment: Readonly<Record<string, string>>,
validation: "fragment" | "effective",
resolveProviderRequestBudget?: ProviderRequestBudgetResolver,
): Result<ClientRegistrationEnvironmentDetails, ConfigurationError> => {
const validated =
validation === "fragment"
? parseEnvironmentValues(environment)
: parseEnvironment(environment);
if (!validated.ok) return validated;
const providerRequestBudget =
resolveProviderRequestBudget?.(
validated.value.REA_ANALYSIS_PROVIDER,
environment,
) ?? ok(undefined);
if (!providerRequestBudget.ok) return providerRequestBudget;
return ok({
values: Object.fromEntries(
Object.entries(environment).sort(([left], [right]) =>
@@ -103,6 +125,9 @@ const validatedDetails = (
...(validated.value.REA_JEB_MCP_TIMEOUT_MS === undefined
? {}
: { jebMcpTimeoutMs: validated.value.REA_JEB_MCP_TIMEOUT_MS }),
...(providerRequestBudget.value === undefined
? {}
: { providerRequestTimeoutMs: providerRequestBudget.value }),
},
});
};
+21 -3
View File
@@ -35,6 +35,7 @@ import type { SetupClient } from "./SupportedClients.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import { dshDocument } from "./DshCordisPatch.js";
import { clientRegistrationEnvironmentDetails } from "./ClientRegistrationEnvironment.js";
import type { ProviderRequestBudgetResolver } from "./ClientRegistrationEnvironment.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
interface ClientRegistrationStatusBase {
@@ -98,6 +99,7 @@ interface ClientRegistrationReadOptions {
readonly platform?: NodeJS.Platform;
readonly environment?: NodeJS.ProcessEnv;
readonly resources?: ArtifactResourceScope;
readonly resolveProviderRequestBudget?: ProviderRequestBudgetResolver;
}
/** Registration facts shared by diagnostics and update maintenance. */
@@ -227,10 +229,26 @@ export const readClientRegistrationInspections = async (
const registration = parseRegistration(raw, effectiveClient);
const registrationEnvironment =
client.name === "kimi" || client.name === "codex"
? clientRegistrationEnvironmentDetails(parsed)
? clientRegistrationEnvironmentDetails(
parsed,
{},
"effective",
options.resolveProviderRequestBudget,
)
: undefined;
if (registrationEnvironment !== undefined && !registrationEnvironment.ok)
throw registrationEnvironment.error;
if (
registrationEnvironment !== undefined &&
!registrationEnvironment.ok
) {
record({
client: client.name,
config_path: configPath,
command: [],
state: "invalid",
remediation: registrationEnvironment.error.message,
});
continue;
}
const command: RegistrationCommand = [
registration.command,
...registration.args,
+13
View File
@@ -36,6 +36,7 @@ import {
type ClientRegistrationStatus,
type UnhealthyClientRegistrationStatus,
} from "./ClientRegistrationStatus.js";
import type { ProviderRequestBudgetResolver } from "./ClientRegistrationEnvironment.js";
import {
collectDoctorDiagnostics,
doctorHealthy,
@@ -105,6 +106,8 @@ export interface DoctorHost {
brewHopperPath(): Promise<string | undefined>;
manualHopperPaths(): Promise<readonly string[]>;
providerInspections?(): Promise<readonly DoctorProviderInspection[]>;
/** Resolve a parsed upstream request budget from the effective client environment. */
resolveProviderRequestBudget?: ProviderRequestBudgetResolver;
/** Validated provider settings preserved by setup, independent of readiness. */
providerRegistrationEnvironment?(): Readonly<Record<string, string>>;
installationPaths?(): Promise<readonly string[]>;
@@ -431,6 +434,7 @@ export interface SystemDoctorHostOptions {
readonly providerRegistrationEnvironment?: () => Readonly<
Record<string, string>
>;
readonly resolveProviderRequestBudget?: ProviderRequestBudgetResolver;
readonly linuxDemoRuntimeCheck?: () => Promise<DoctorCheck>;
}
@@ -547,6 +551,9 @@ export const systemDoctorHost = (
providerRegistrationEnvironment:
options.providerRegistrationEnvironment,
}),
...(options.resolveProviderRequestBudget === undefined
? {}
: { resolveProviderRequestBudget: options.resolveProviderRequestBudget }),
async ilspyCmdVersion(path) {
try {
await access(path, constants.X_OK);
@@ -599,6 +606,12 @@ export const systemDoctorHost = (
platform,
environment,
resources,
...(options.resolveProviderRequestBudget === undefined
? {}
: {
resolveProviderRequestBudget:
options.resolveProviderRequestBudget,
}),
}),
};
};
@@ -18,6 +18,7 @@ export const workflowAnalysisProfile = (
): AnalysisProfileCommitment =>
createAnalysisProfile(REA_WORKFLOW_PROVIDER, {
upstream_analysis_profile: upstream,
source_limitations: "provider-operation-caveats-v1",
...(operation === "binary_overview"
? { workflow_revision: 4 }
: operation === "batch_decompile" ||
+29 -1
View File
@@ -6,6 +6,7 @@ import {
type ClientConfigurationSource,
} from "./ClientConfigurationFiles.js";
import { err, ok, type Result } from "../domain/result.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import {
clientRegistrationEntry,
clientConfigurationValuesEqual,
@@ -26,6 +27,7 @@ import writeFileAtomic from "write-file-atomic";
import {
clientRegistrationEnvironment,
clientRegistrationEnvironmentDetails,
type ProviderRequestBudgetResolver,
} from "./ClientRegistrationEnvironment.js";
import { PRODUCT_IDENTITY } from "../identity.js";
@@ -81,16 +83,23 @@ export const configureClientConfiguration = (
client: SetupClient,
environment: SetupProviderEnvironment = {},
command: readonly string[] = npxRegistrationCommand(),
resolveProviderRequestBudget?: ProviderRequestBudgetResolver,
): Promise<ClientConfigurationResult> => {
if (client.configPathError !== undefined)
return Promise.resolve({ status: "failed", reason: "path" });
return configureClientDocument(client, environment, command);
return configureClientDocument(
client,
environment,
command,
resolveProviderRequestBudget,
);
};
const configureClientDocument = async (
client: SetupClient,
environment: SetupProviderEnvironment,
command: readonly string[],
resolveProviderRequestBudget?: ProviderRequestBudgetResolver,
): Promise<ClientConfigurationResult> => {
const format = client.format;
if (format === undefined || format === "unsupported")
@@ -118,6 +127,7 @@ const configureClientDocument = async (
command,
existing,
files.value,
resolveProviderRequestBudget,
),
);
if (!policy.ok) {
@@ -156,6 +166,7 @@ const configureClientDocument = async (
command,
parsed,
files.value,
resolveProviderRequestBudget,
),
);
if (!policy.ok) {
@@ -184,6 +195,7 @@ const configureClientDocument = async (
servers: { ...fallback.value, ...parsed.servers },
},
files.value,
resolveProviderRequestBudget,
);
if (registrationCurrent(parsed, desired)) return { status: "unchanged" };
return persistClientConfiguration({
@@ -353,6 +365,7 @@ export const clientConfigurationAligned = async (
client: SetupClient,
providerEnvironment: SetupProviderEnvironment,
command: readonly string[],
resolveProviderRequestBudget?: ProviderRequestBudgetResolver,
): Promise<boolean> => {
if (client.configPathError !== undefined) return false;
try {
@@ -372,6 +385,7 @@ export const clientConfigurationAligned = async (
command,
parsed,
files.value,
resolveProviderRequestBudget,
),
);
if (!policy.ok) {
@@ -388,6 +402,7 @@ export const clientConfigurationAligned = async (
command,
parsed,
files.value,
resolveProviderRequestBudget,
),
)
);
@@ -404,11 +419,13 @@ export const inspectClientConfiguration = async (
client: SetupClient,
providerEnvironment: SetupProviderEnvironment,
command: readonly string[],
resolveProviderRequestBudget?: ProviderRequestBudgetResolver,
): Promise<ClientConfigurationInspection> => {
const inspection = await inspectClientDocument(
client,
providerEnvironment,
command,
resolveProviderRequestBudget,
);
if (inspection.status !== "create" && inspection.status !== "update")
return inspection;
@@ -423,6 +440,7 @@ const inspectClientDocument = async (
client: SetupClient,
providerEnvironment: SetupProviderEnvironment,
command: readonly string[],
resolveProviderRequestBudget?: ProviderRequestBudgetResolver,
): Promise<ClientConfigurationInspection> => {
if (client.configPathError !== undefined)
return { status: "invalid", remediation: client.configPathError };
@@ -456,6 +474,7 @@ const inspectClientDocument = async (
command,
existing,
files.value,
resolveProviderRequestBudget,
),
);
if (!policy.ok) {
@@ -505,6 +524,7 @@ const inspectClientDocument = async (
command,
parsed,
files.value,
resolveProviderRequestBudget,
),
);
if (!policy.ok) {
@@ -522,6 +542,7 @@ const inspectClientDocument = async (
command,
parsed,
files.value,
resolveProviderRequestBudget,
);
if (registrationCurrent(parsed, desired))
return { status: "already_current" };
@@ -532,6 +553,8 @@ const inspectClientDocument = async (
}
} catch (cause: unknown) {
if (cause instanceof RegularFileCleanupFailure) throw cause;
if (cause instanceof ConfigurationError)
return { status: "invalid", remediation: cause.message };
// Malformed configuration is reported with the invalid remediation.
void cause;
return {
@@ -660,6 +683,7 @@ const clientConfigurationDesired = (
command: readonly string[],
parsed: ClientConfigurationDocument | undefined,
sources: readonly ClientConfigurationSource[],
resolveProviderRequestBudget?: ProviderRequestBudgetResolver,
) => {
const effective = effectiveClientConfiguration(sources, {
client,
@@ -685,6 +709,10 @@ const clientConfigurationDesired = (
const validated = clientRegistrationEnvironmentDetails(
effective,
providerEnvironment,
"effective",
client.name === "codex" || client.name === "kimi"
? resolveProviderRequestBudget
: undefined,
);
if (!validated.ok) throw validated.error;
// Preserve explicitly configured server settings, never the ambient process
+21 -4
View File
@@ -244,7 +244,10 @@ export const systemSetupHost = (
detectedClients: () => detectClients(homeDirectory, platform, environment),
supportedClients: () =>
Promise.resolve(supportedClients(homeDirectory, platform, environment)),
...clientConfigurationOperations(resources),
...clientConfigurationOperations(
resources,
doctorHost.resolveProviderRequestBudget,
),
skillNeedsInstall: (clientIds) =>
canonicalSkillNeedsInstall(
homeDirectory,
@@ -288,7 +291,10 @@ const setupInstallerDiagnostic = (
: `${normalized.slice(0, 397).trimEnd()}...`;
};
const clientConfigurationOperations = (resources: ArtifactResourceScope) => ({
const clientConfigurationOperations = (
resources: ArtifactResourceScope,
resolveProviderRequestBudget: DoctorHost["resolveProviderRequestBudget"],
) => ({
configureClient: (
client: SetupClient,
providerEnvironment: SetupProviderEnvironment,
@@ -297,7 +303,12 @@ const clientConfigurationOperations = (resources: ArtifactResourceScope) => ({
runClientConfigurationOperation(resources, async () =>
client.format === "unsupported"
? { status: "skipped" as const }
: configureClientConfiguration(client, providerEnvironment, command),
: configureClientConfiguration(
client,
providerEnvironment,
command,
resolveProviderRequestBudget,
),
),
clientNeedsConfigure: (
client: SetupClient,
@@ -311,6 +322,7 @@ const clientConfigurationOperations = (resources: ArtifactResourceScope) => ({
client,
providerEnvironment,
command,
resolveProviderRequestBudget,
)),
),
inspectClientConfiguration: (
@@ -319,7 +331,12 @@ const clientConfigurationOperations = (resources: ArtifactResourceScope) => ({
command: readonly string[],
) =>
runClientConfigurationOperation(resources, () =>
inspectClientConfiguration(client, providerEnvironment, command),
inspectClientConfiguration(
client,
providerEnvironment,
command,
resolveProviderRequestBudget,
),
),
});
+62
View File
@@ -0,0 +1,62 @@
import type {
AnalysisOperation,
ProviderIdentity,
} from "./AnalysisProvider.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
/** One provider-owned caveat tied to the operation that reported it. */
export interface SourceLimitation {
readonly provider: ProviderIdentity;
readonly operation: AnalysisOperation;
readonly limitation: string;
}
/** Stable exact-provenance key for deduplication before rendering. */
export const sourceLimitationKey = ({
provider,
operation,
limitation,
}: SourceLimitation): string =>
JSON.stringify([
provider.id,
provider.name,
provider.version,
operation,
limitation,
]);
/** Render unique source caveats without losing their provider or operation. */
export const renderSourceLimitations = (
limitations: readonly SourceLimitation[],
): string[] => {
const groups = new Map<
string,
{
readonly provider: ProviderIdentity;
readonly limitation: string;
readonly operations: Set<AnalysisOperation>;
}
>();
for (const { provider, operation, limitation } of limitations) {
const key = JSON.stringify([
provider.id,
provider.name,
provider.version,
limitation,
]);
const group = groups.get(key) ?? {
provider,
limitation,
operations: new Set<AnalysisOperation>(),
};
group.operations.add(operation);
groups.set(key, group);
}
return [...groups.values()]
.map(({ provider, limitation, operations }) => {
const version = provider.version === null ? "" : ` ${provider.version}`;
const operationList = [...operations].sort(compareUnicodeCodePoints);
return `${provider.name} (${provider.id}${version}) ${operationList.join(", ")}: ${limitation}`;
})
.sort(compareUnicodeCodePoints);
};
+48 -5
View File
@@ -95,16 +95,18 @@ it("retains unique upstream limits in canonical order across concurrent observat
let calls = 0;
const options = { signal: new AbortController().signal };
const analysis: AnalysisOperationPort = {
execute: async (_, parameters, forwarded) => {
execute: async (operation, parameters, forwarded) => {
expect(parameters).toEqual({ marker: true });
expect(forwarded).toBe(options);
const call = calls++;
if (call === 0) await gate;
else release();
const limitation =
operation === "list_strings"
? ["first", "shared"]
: ["second", "shared"];
return ok(
createAnalysisExecution([], provider, {
limitations: call === 0 ? ["first", "shared"] : ["second", "shared"],
}),
createAnalysisExecution([], provider, { limitations: limitation }),
);
},
};
@@ -117,7 +119,48 @@ it("retains unique upstream limits in canonical order across concurrent observat
});
expect(calls).toBe(2);
expect(result).toEqual(
ok({ result: { count: 0 }, limitations: ["first", "second", "shared"] }),
ok({
result: { count: 0 },
limitations: [
"Fixture (fixture 1) list_procedures, list_strings: shared",
"Fixture (fixture 1) list_procedures: second",
"Fixture (fixture 1) list_strings: first",
],
}),
);
});
it("deduplicates exact source caveats without merging providers or operations", async () => {
const firstProvider = { id: "first", name: "First", version: "1" };
const secondProvider = { id: "second", name: "Second", version: "1" };
const result = await executeComposedWorkflow(
{
execute: async (operation, parameters) =>
ok(
createAnalysisExecution(
null,
parameters.provider === "second" ? secondProvider : firstProvider,
{ limitations: ["same source caveat"] },
),
),
},
async (observed) => {
await observed.execute("list_strings", { provider: "first" });
await observed.execute("list_strings", { provider: "first" });
await observed.execute("list_strings", { provider: "second" });
await observed.execute("list_procedures", { provider: "first" });
return ok({ count: 0 });
},
);
expect(result).toEqual(
ok({
result: { count: 0 },
limitations: [
"First (first 1) list_procedures, list_strings: same source caveat",
"Second (second 1) list_strings: same source caveat",
],
}),
);
});
+15 -6
View File
@@ -23,7 +23,11 @@ import {
import type { JsonValue } from "../domain/jsonValue.js";
import type { UnknownRegistryPort } from "./investigation/InvestigationRecordPort.js";
import { UnknownRegistryError } from "../domain/unknownRegistryError.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import {
renderSourceLimitations,
sourceLimitationKey,
type SourceLimitation,
} from "./SourceLimitations.js";
/** Retain the limits of exactly the observations used by one composed call. */
export const executeComposedWorkflow = async (
@@ -37,21 +41,26 @@ export const executeComposedWorkflow = async (
AnalysisError
>
> => {
const limitations = new Set<string>();
const limitations = new Map<string, SourceLimitation>();
const result = await execute({
execute: async (operation, parameters, options) => {
const execution = await analysis.execute(operation, parameters, options);
if (execution.ok)
execution.value.limitations.forEach((limitation) =>
limitations.add(limitation),
);
for (const limitation of execution.value.limitations) {
const fact = {
provider: execution.value.provider,
operation,
limitation,
} satisfies SourceLimitation;
limitations.set(sourceLimitationKey(fact), fact);
}
return execution;
},
});
if (!result.ok) return result;
return ok({
result: result.value,
limitations: [...limitations].sort(compareUnicodeCodePoints),
limitations: renderSourceLimitations([...limitations.values()]),
});
};
@@ -257,6 +257,56 @@ describe("native UI action trace", () => {
}),
);
});
it("carries source-owned reference limitations into trace output without changing coverage", async () => {
const limitation = "SEH handler targets may be outside the function body.";
const run = async (limitations: readonly string[]) => {
const result = await new EnhancedTools(
testAnalysis({
procedure_address: () => execution("procedure_address", "0x1000"),
procedure_references: () =>
ok(
createAnalysisExecution(
{
reference_kinds_available: false,
references: [],
unresolved_calls: [],
},
{ id: "ghidra", name: "Ghidra", version: "fixture" },
{ limitations },
),
),
procedure_callees: (parameters) =>
execution(
"procedure_callees",
parameters.procedure === "0x1000" ? ["0x2000"] : [],
),
}),
).execute("trace_native_ui_action", { action: "0x1000" });
if (!result.ok) throw result.error;
return nativeInvestigationTraceSchema.parse(result.value);
};
const withSourceLimitation = await run([limitation]);
const withoutSourceLimitation = await run([]);
expect(withSourceLimitation.limitations).toContain(
`Ghidra (ghidra fixture) procedure_references: ${limitation}`,
);
expect(
withSourceLimitation.limitations.filter((entry) =>
entry.endsWith(`procedure_references: ${limitation}`),
),
).toHaveLength(1);
expect(withSourceLimitation.coverage).toEqual(
withoutSourceLimitation.coverage,
);
expect(withSourceLimitation.coverage).toContainEqual(
expect.objectContaining({
facet: "static_native_calls",
status: "partial",
}),
);
});
it("accepts an explicit native seed without an authored UI connection", async () => {
const result = await new EnhancedTools(
testAnalysis({
@@ -34,6 +34,12 @@ describe("untyped native call route fallback", () => {
operation === "procedure_references"
? referenceProvider
: fallbackProvider,
{
limitations:
operation === "procedure_references"
? ["Reference-kind metadata is incomplete."]
: ["Callee enumeration is limited to named functions."],
},
),
);
},
@@ -54,6 +60,18 @@ describe("untyped native call route fallback", () => {
resolution: "inferred",
})),
unknowns: [{ ...unknown, provider: referenceProvider }],
limitations: [
{
provider: referenceProvider,
operation: "procedure_references",
limitation: "Reference-kind metadata is incomplete.",
},
{
provider: fallbackProvider,
operation: "procedure_callees",
limitation: "Callee enumeration is limited to named functions.",
},
],
},
});
},
@@ -105,6 +123,7 @@ describe("typed native call routes", () => {
],
},
provider,
{ limitations: ["SEH handler targets may be outside the body."] },
),
),
},
@@ -126,6 +145,13 @@ describe("typed native call routes", () => {
},
],
unknowns: [{ address: "0x1008" }],
limitations: [
{
provider,
operation: "procedure_references",
limitation: "SEH handler targets may be outside the body.",
},
],
},
});
});
@@ -146,12 +172,26 @@ describe("typed native call routes", () => {
});
expect(calls).toBe(1);
const malformed = await readNativeCallRoutes(
{ execute: async () => ok(createAnalysisExecution({}, provider)) },
{
execute: async () =>
ok(
createAnalysisExecution({}, provider, {
limitations: ["Malformed output still has source caveats."],
}),
),
},
"0x1000",
);
expect(malformed).toMatchObject({
ok: false,
error: { _tag: "AnalysisOutputError" },
limitations: [
{
provider,
operation: "procedure_references",
limitation: "Malformed output still has source caveats.",
},
],
});
});
it("rejects omitted unresolved-call observations instead of claiming none", async () => {
+61 -28
View File
@@ -9,6 +9,14 @@ import {
referenceKindSchema,
} from "../../domain/hopperValues.js";
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
import type { SourceLimitation } from "../SourceLimitations.js";
const limitationsFrom = (
operation: SourceLimitation["operation"],
provider: ProviderIdentity,
limitations: readonly string[],
): SourceLimitation[] =>
limitations.map((limitation) => ({ provider, operation, limitation }));
const referencesSchema = z.object({
reference_kinds_available: z.boolean(),
@@ -24,6 +32,33 @@ const referencesSchema = z.object({
z.object({ address: z.string(), reason: z.string() }),
),
});
const typedCallRoutes = (
references: z.infer<typeof referencesSchema>["references"],
) => {
const calls = references.filter(({ kind }) => kind.available && kind.call);
const countBySite = new Map<string, Set<string>>();
for (const call of calls) {
const targets = countBySite.get(call.source_address) ?? new Set<string>();
targets.add(call.target_address);
countBySite.set(call.source_address, targets);
}
return calls.map((call) => ({
address: call.target_procedure?.address ?? call.target_address,
site: call.source_address,
relation:
call.kind.available && (call.kind.computed || call.kind.indirect)
? ("indirect_call" as const)
: ("direct_call" as const),
resolution:
(countBySite.get(call.source_address)?.size ?? 0) > 1
? ("ambiguous" as const)
: call.kind.available && (call.kind.computed || call.kind.indirect)
? ("resolved" as const)
: ("observed" as const),
}));
};
/** Prefer typed static call references, preserving computed targets and targetless sites. */
export const readNativeCallRoutes = async (
analysis: AnalysisOperationPort,
@@ -45,7 +80,13 @@ export const readNativeCallRoutes = async (
reason: string;
provider: ProviderIdentity;
}> = [];
let limitations: SourceLimitation[] = [];
if (execution.ok) {
limitations = limitationsFrom(
"procedure_references",
execution.value.provider,
execution.value.limitations,
);
const parsed = referencesSchema.safeParse(execution.value.result);
if (!parsed.success)
return {
@@ -55,6 +96,7 @@ export const readNativeCallRoutes = async (
"Invalid typed call references",
),
unknowns,
limitations,
};
// Missing detailed reference flags do not invalidate observed targetless
// calls. Keep their producing provider even if another provider supplies
@@ -68,48 +110,38 @@ export const readNativeCallRoutes = async (
parsed.data.reference_kinds_available === true ||
(references.length > 0 && references.every(({ kind }) => kind.available))
) {
const calls = references.filter(
({ kind }) => kind.available && kind.call,
);
const countBySite = new Map<string, Set<string>>();
for (const call of calls) {
const targets =
countBySite.get(call.source_address) ?? new Set<string>();
targets.add(call.target_address);
countBySite.set(call.source_address, targets);
}
return {
ok: true as const,
value: {
provider: execution.value.provider,
routes: calls.map((call) => ({
address: call.target_procedure?.address ?? call.target_address,
site: call.source_address,
relation:
call.kind.available && (call.kind.computed || call.kind.indirect)
? ("indirect_call" as const)
: ("direct_call" as const),
resolution:
(countBySite.get(call.source_address)?.size ?? 0) > 1
? ("ambiguous" as const)
: call.kind.available &&
(call.kind.computed || call.kind.indirect)
? ("resolved" as const)
: ("observed" as const),
})),
routes: typedCallRoutes(references),
unknowns,
limitations,
},
};
}
} else if (execution.error._tag !== "AnalysisCapabilityUnavailableError")
return { ok: false as const, error: execution.error, unknowns };
return {
ok: false as const,
error: execution.error,
unknowns,
limitations,
};
const callees = await analysis.execute(
"procedure_callees",
{ procedure },
options,
);
if (!callees.ok)
return { ok: false as const, error: callees.error, unknowns };
return { ok: false as const, error: callees.error, unknowns, limitations };
limitations = [
...limitations,
...limitationsFrom(
"procedure_callees",
callees.value.provider,
callees.value.limitations,
),
];
const parsed = parseRelatedAddresses(callees.value.result, "callees");
return parsed.ok
? {
@@ -123,7 +155,8 @@ export const readNativeCallRoutes = async (
resolution: "inferred" as const,
})),
unknowns,
limitations,
},
}
: { ok: false as const, error: parsed.error, unknowns };
: { ok: false as const, error: parsed.error, unknowns, limitations };
};
@@ -14,6 +14,10 @@ import { nativeDispatchMetadataResultSchema } from "../../domain/native/objcSwif
import { inspectNativeDispatch } from "./NativeDispatchMetadataInspection.js";
import { jsonValueSchema } from "../../domain/jsonValue.js";
import { readNativeCallRoutes } from "./NativeCallRoutes.js";
import {
renderSourceLimitations,
type SourceLimitation,
} from "../SourceLimitations.js";
import { err, ok } from "../../domain/result.js";
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
@@ -244,6 +248,10 @@ export const traceNativeUiAction = async (
: graph.truncated && trace.reason === null
? "native_trace_output_bound_reached"
: trace.reason,
limitations: [
...trace.limitations,
...renderSourceLimitations(expanded.limitations),
],
}),
),
);
@@ -398,6 +406,7 @@ const addDirectCallees = async (input: {
let examined = 0;
let omitted = 0;
let truncated = false;
const sourceLimitations: SourceLimitation[] = [];
while (queueIndex < queue.length) {
const current = queue[queueIndex++];
if (current === undefined || visited.has(current.address)) continue;
@@ -419,6 +428,9 @@ const addDirectCallees = async (input: {
"provider",
);
examined += 1;
sourceLimitations.push(
...(execution.ok ? execution.value.limitations : execution.limitations),
);
if (!execution.ok && execution.error._tag === "AnalysisCancelledError")
return { error: execution.error };
const unknowns = execution.ok
@@ -539,6 +551,7 @@ const addDirectCallees = async (input: {
examined,
omitted,
truncated,
limitations: sourceLimitations,
};
};
+58 -16
View File
@@ -291,31 +291,32 @@ const nativeFlowDossier = (
});
};
const callBindingAnalysis = (
callerAddress: string,
calleeAddress: string,
caller: ReturnType<typeof jsonValueSchema.parse>,
callee: ReturnType<typeof jsonValueSchema.parse>,
): AnalysisOperationPort => ({
const callBindingAnalysis = (input: {
readonly callerAddress: string;
readonly calleeAddress: string;
readonly caller: ReturnType<typeof jsonValueSchema.parse>;
readonly callee: ReturnType<typeof jsonValueSchema.parse>;
readonly targetLimitations?: readonly string[];
}): AnalysisOperationPort => ({
execute: async (operation, parameters) => {
if (operation === "resolve_native_call_targets")
return ok(
createAnalysisExecution(
{
call_site: String(parameters.address),
procedure: calleeAddress,
procedure: input.calleeAddress,
status: "direct",
mechanism: "direct",
targets: [
{
address: calleeAddress,
procedure: calleeAddress,
address: input.calleeAddress,
procedure: input.calleeAddress,
status: "direct",
basis: "provider-reference",
references: [],
},
],
limitations: [],
limitations: [...(input.targetLimitations ?? [])],
},
provider,
{ subject },
@@ -324,13 +325,15 @@ const callBindingAnalysis = (
if (operation !== "analyze_function")
throw new Error(`Unexpected native trace operation: ${operation}`);
if (
parameters.procedure !== callerAddress &&
parameters.procedure !== calleeAddress
parameters.procedure !== input.callerAddress &&
parameters.procedure !== input.calleeAddress
)
throw new Error(`Unexpected procedure: ${String(parameters.procedure)}`);
return ok(
createAnalysisExecution(
parameters.procedure === callerAddress ? caller : callee,
parameters.procedure === input.callerAddress
? input.caller
: input.callee,
provider,
{ subject },
),
@@ -430,6 +433,40 @@ describe("native value failed-callee traversal", () => {
});
describe("native value call-binding fan-out", () => {
it("retains limitations embedded in resolved call-target results", async () => {
const callerAddress = "0x1000";
const calleeAddress = "0x2000";
const base = functionDossierSchema.parse(ghidraFunctionDossier());
const caller = nativeFlowDossier(base, {
address: callerAddress,
name: "caller",
operations: [node("call", callerAddress, "CALL")],
parameters: [],
});
const callee = nativeFlowDossier(base, {
address: calleeAddress,
name: "callee",
operations: [],
parameters: [],
});
const caveat = "Only statically resolved call sites are included.";
const result = await traceNativeValues(
callBindingAnalysis({
callerAddress,
calleeAddress,
caller,
callee,
targetLimitations: [caveat],
}),
{ procedure: callerAddress, max_functions: 2 },
);
if (!result.ok) throw result.error;
expect(nativeValueTraceSchema.parse(result.value).limitations).toContain(
`Fixture (fixture 1) resolve_native_call_targets: ${caveat}`,
);
});
it("preserves binding order and edge-budget behavior for repeated callees", async () => {
const callCount = 128;
const callerAddress = "0x1000";
@@ -455,12 +492,12 @@ describe("native value call-binding fan-out", () => {
operations: [node("return", calleeAddress, "RETURN")],
parameters: [{ ordinal: 0, name: "value", data_type: "/int" }],
});
const fanoutAnalysis = callBindingAnalysis(
const fanoutAnalysis = callBindingAnalysis({
callerAddress,
calleeAddress,
caller,
callee,
);
});
const expectedEdges = [
...Array.from({ length: callCount }, (_, index) => ({
source: `${callerAddress}/call-${index}`,
@@ -803,7 +840,12 @@ describe("native value byte-bound call bindings", () => {
],
});
const result = await traceNativeValues(
callBindingAnalysis("0x1000", "0x2000", caller, callee),
callBindingAnalysis({
callerAddress: "0x1000",
calleeAddress: "0x2000",
caller,
callee,
}),
{ procedure: "0x1000" },
);
if (!result.ok) throw result.error;
@@ -13,6 +13,10 @@ import { err, ok } from "../../domain/result.js";
import type { AnalysisOperationPort } from "../AnalysisProvider.js";
import type { EnhancedResult } from "../EnhancedToolTypes.js";
import { jsonParts } from "../../domain/jsonSerialization.js";
import {
renderSourceLimitations,
type SourceLimitation,
} from "../SourceLimitations.js";
const NODE_PAYLOAD_BYTES = 8 * 1024 * 1024;
@@ -27,6 +31,7 @@ export const traceNativeValues = async (
const edges: typeof nativeValueTraceSchema._output.edges = [];
const procedures: typeof nativeValueTraceSchema._output.procedures = [];
const unknowns: typeof nativeValueTraceSchema._output.unknowns = [];
const sourceLimitations: SourceLimitation[] = [];
const callBindings: {
caller: string;
callee: string;
@@ -280,6 +285,13 @@ export const traceNativeValues = async (
"Invalid static call resolution",
),
);
sourceLimitations.push(
...targets.data.limitations.map((limitation) => ({
provider: resolved.value.provider,
operation: "resolve_native_call_targets" as const,
limitation,
})),
);
if (targets.data.targets.length === 0)
unknowns.push({
procedure: address,
@@ -424,6 +436,7 @@ export const traceNativeValues = async (
"Static decompiler def-use graphs cover recovered block members; when membership is unavailable they exclude operations marked dead. Logical CALL argument positions bind to recovered HighParam ordinals and RETURN values bind to recovered CALL outputs; these are decompiler-derived dependencies, not original source or runtime observations. Missing parameters, variadic arguments and alias dependencies remain unknown.",
"LOAD and STORE are memory operations; object fields, persistent state and RNG semantics are not inferred from names or pseudocode.",
"Traversal caps depth, decompilations, call-site resolutions, nodes, edges and node payloads (8 MiB). Complete serialized output is bounded to 32 MiB. Pagination reruns analysis and returns edges whose source is on the node page; endpoints outside the page retain stable identities.",
...renderSourceLimitations(sourceLimitations),
],
});
if (Buffer.byteLength(JSON.stringify(result)) > 32 * 1024 * 1024)
+17
View File
@@ -7,8 +7,10 @@ import { fileURLToPath } from "node:url";
import { inspectJadxProvider } from "./android/JadxDoctor.js";
import { inspectSystemGhidraProvider } from "./ghidra/GhidraDoctor.js";
import { inspectIdaRegistration } from "./ida/IdaDoctor.js";
import { readIdaConfiguration } from "./ida/IdaConfiguration.js";
import { parseJebMcpEndpoint } from "./jeb/JebMcpEndpoint.js";
import { selectLinuxPrivateDisplayStrategy } from "./hopper/LinuxPrivateDisplayProbe.js";
import { ok } from "./domain/result.js";
/** Compose provider diagnostics at the outer CLI adapter boundary. */
export const createSystemDoctorHost = (
@@ -24,6 +26,21 @@ export const createSystemDoctorHost = (
return systemDoctorHost({
environment,
providerRegistrationEnvironment: () => selectedJebRegistrationEnvironment,
resolveProviderRequestBudget: (
analysisProvider,
registrationEnvironment,
) => {
if (analysisProvider !== "ida" && analysisProvider !== "auto")
return ok(undefined);
const path = registrationEnvironment.REA_IDA_MCP_CONFIG;
if (path === undefined) return ok(undefined);
const registration = readIdaConfiguration(path);
if (registration.ok) return ok(registration.value.timeoutMs);
// An invalid IDA registration cannot be selected automatically. Keep it
// out of recommendations for another available provider, but surface the
// parser-owned error when IDA itself was explicitly selected.
return analysisProvider === "ida" ? registration : ok(undefined);
},
providerInspections: async () => [
await inspectSystemGhidraProvider(environment),
inspectIdaRegistration(environment.REA_IDA_MCP_CONFIG),
+7 -6
View File
@@ -42,6 +42,9 @@ import {
} from "../domain/native/nativeAotPe.js";
const callRelationshipCoverage = "function-body-call-coverage-v5";
const functionBoundaryObservations = "ghidra-function-boundary-limitations-v4";
const instructionReferenceCoverage =
"exact-address-incoming-without-synthetic-entry-v2";
/**
* The processor language REA pins for this target, or undefined when Ghidra may
@@ -136,9 +139,8 @@ export const resolveGhidraAnalysisProfile = async (
procedure_file_offset_resolution:
"address-first-unique-target-source-mapping-v2",
instruction_flow_evidence: "decoded-return-pcode-v1",
instruction_references: "exact-address-incoming-v1",
function_boundary_observations:
"ghidra-function-boundary-limitations-v3",
instruction_references: instructionReferenceCoverage,
function_boundary_observations: functionBoundaryObservations,
no_return_repair:
installation.platform === "win32"
? "disabled-windows-p0"
@@ -236,9 +238,8 @@ export const resolveGhidraAnalysisProfile = async (
procedure_file_offset_resolution:
"address-first-unique-target-source-mapping-v2",
instruction_flow_evidence: "decoded-return-pcode-v1",
instruction_references: "exact-address-incoming-v1",
function_boundary_observations:
"ghidra-function-boundary-limitations-v3",
instruction_references: instructionReferenceCoverage,
function_boundary_observations: functionBoundaryObservations,
no_return_repair:
installation.platform === "win32"
? "disabled-windows-p0"
+38
View File
@@ -27,6 +27,7 @@ const observationOperations: ReadonlySet<GhidraFunctionOperation> = new Set([
"procedure_callers",
"procedure_info",
"procedure_pseudo_code",
"procedure_references",
"read_function_instructions",
]);
@@ -240,6 +241,43 @@ describe("Ghidra relationship metadata validation", () => {
});
});
it("preserves handler-region limitations on procedure references", () => {
const limitation =
"Ghidra Listing shows a Borland/Delphi-style SEH frame registration at 0x401008 (push 0x4010a0 followed by fs-segment stack chaining); handler 0x4010a0 lies outside this procedure's body, so callees, strings, and cross-references may omit the handler region.";
const parsed = parseGhidraFunctionResult("procedure_references", {
value: {
procedure: ghidraFunctionIdentity(),
direction: "outgoing",
references: [ghidraReferenceEdge()],
reference_kinds_available: true,
unresolved_calls: [],
},
limitations: [limitation],
});
expect(parsed).toEqual({
ok: true,
value: {
value: {
procedure: ghidraFunctionIdentity(),
direction: "outgoing",
references: [ghidraReferenceEdge()],
reference_kinds_available: true,
unresolved_calls: [],
},
limitations: [limitation],
},
});
expect(
parseGhidraFunctionResult("procedure_references", {
procedure: ghidraFunctionIdentity(),
direction: "outgoing",
references: [],
reference_kinds_available: true,
unresolved_calls: [],
}),
).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" } });
});
it.each(["procedure_callees", "procedure_callers"] as const)(
"requires the function-observation envelope to carry terminal-call limitations for %s",
(operation) => {
+1
View File
@@ -418,6 +418,7 @@ const functionBoundaryObservationOperations: ReadonlySet<GhidraFunctionOperation
"procedure_callers",
"procedure_info",
"procedure_pseudo_code",
"procedure_references",
"read_function_instructions",
]);
+11 -1
View File
@@ -190,9 +190,19 @@ describe("Ghidra relationship coverage replay", () => {
describe("Ghidra snapshot profile admission", () => {
it.each([
{
parameter: "function_boundary_observations",
current: "ghidra-function-boundary-limitations-v4",
legacy: "ghidra-function-boundary-limitations-v3",
},
{
parameter: "instruction_references",
current: "exact-address-incoming-v1",
current: "exact-address-incoming-without-synthetic-entry-v2",
legacy: "exact-address-incoming-v1",
},
{
parameter: "instruction_references",
current: "exact-address-incoming-without-synthetic-entry-v2",
legacy: undefined,
},
{
+28
View File
@@ -61,6 +61,34 @@ it("sleeps the fixed retry delay between busy reads and returns the settled answ
expect(sleeps, "two fixed 100 ms pauses").toEqual([100, 100]);
});
it("returns an expired readiness timeout without starting a health read", async () => {
const timeoutMs = 10;
const { connection, reads } = scripted([ok]);
let clockReads = 0;
const clock = {
now: () => (clockReads++ === 0 ? 0 : 11),
delay: async () => {
throw new Error("An expired wait must not schedule a pause");
},
};
const failure = await readSettledHealth(connection, {
database: "db",
timeoutMs,
clock,
}).catch((cause: unknown) => cause);
expect(failure).toMatchObject({
_tag: "AnalysisTimeoutError",
operation: "server_health",
timeoutMs,
});
expect(
reads,
"an expired budget never becomes an SDK timeout of zero",
).toHaveLength(0);
});
it("clamps the last pause to the remaining budget and stops without another read at expiry", async () => {
const { connection, reads } = scripted([busy("config_json_get")]);
const { clock, sleeps } = virtualClock();
+51 -6
View File
@@ -29,6 +29,11 @@ const isBusyStatus = (raw: JsonValue): boolean =>
!Array.isArray(raw) &&
raw["status"] === "busy";
interface LastBusyHealth {
readonly raw: JsonValue;
readonly operation: string;
}
/**
* Reads `server_health` until the worker stops reporting a transient busy
* state. The wait ends when the worker answers, the caller aborts, or the
@@ -53,22 +58,62 @@ export const readSettledHealth = async (
): Promise<JsonValue> => {
const started = clock.now();
const remaining = (): number => timeoutMs - (clock.now() - started);
let lastBusy: LastBusyHealth | undefined;
for (;;) {
const raw = await connection.call("server_health", { database });
const budgetMs = remaining();
if (budgetMs <= 0)
throw lastBusy === undefined
? new AnalysisTimeoutError("server_health", timeoutMs)
: busyTimeout(lastBusy, timeoutMs);
const requestTimeoutMs = Math.min(timeoutMs, Math.ceil(budgetMs));
let raw: JsonValue;
try {
raw = await connection.call(
"server_health",
{ database },
{ timeoutMs: requestTimeoutMs },
);
} catch (cause: unknown) {
if (
lastBusy !== undefined &&
cause instanceof AnalysisTimeoutError &&
cause.timeoutMs === requestTimeoutMs &&
requestTimeoutMs < timeoutMs
) {
throw new AnalysisTimeoutError(lastBusy.operation, timeoutMs, {
cause: lastBusy.raw,
capturedOutput: cause.capturedOutput,
diagnostics: {
health_request_operation: cause.operation,
health_request_timeout_ms: cause.timeoutMs,
},
});
}
throw cause;
}
const busy = busyHealthSchema.safeParse(raw);
if (!busy.success) {
if (isBusyStatus(raw)) throw busy.error;
return raw;
}
const observedBusy = {
raw,
operation: `server_health: headless worker still busy in ${busyToolName(busy.data.busy_tool)}`,
};
lastBusy = observedBusy;
const expired = (): AnalysisTimeoutError =>
new AnalysisTimeoutError(
`server_health: headless worker still busy in ${busyToolName(busy.data.busy_tool)}`,
timeoutMs,
{ cause: raw },
);
busyTimeout(observedBusy, timeoutMs);
if (remaining() <= 0) throw expired();
await clock.delay(Math.min(BUSY_HEALTH_RETRY_MS, remaining()), signal);
signal?.throwIfAborted();
if (remaining() <= 0) throw expired();
}
};
const busyTimeout = (
lastBusy: LastBusyHealth,
timeoutMs: number,
): AnalysisTimeoutError =>
new AnalysisTimeoutError(lastBusy.operation, timeoutMs, {
cause: lastBusy.raw,
});
+142 -41
View File
@@ -7,6 +7,7 @@ import {
} from "./IdaMcpConnection.js";
import { createIdaDiagnosticRedactor } from "./IdaDiagnosticRedactor.js";
import { AnalysisTimeoutError } from "../domain/analysisErrorCore.js";
import { readSettledHealth } from "./IdaBusyHealth.js";
describe("upstream IDA tool result decoding", () => {
it("leaves successful legacy, structured, and native evidence unchanged", () => {
@@ -144,60 +145,88 @@ const failingHttpConnection = (
);
};
it("preserves the SDK request timeout as a typed IDA timeout", async () => {
const timeoutMs = 40;
const pendingCall = vi.fn();
interface MockedIdaHttpRequest {
readonly id?: string | number;
readonly method: string;
readonly params?: Record<string, unknown>;
}
const jsonRpcResponse = (value: unknown): Response =>
new Response(JSON.stringify(value), {
headers: { "Content-Type": "application/json" },
});
const stalledResponse = (signal: AbortSignal | null | undefined) =>
new Promise<Response>((_resolve, reject) => {
const aborted = () =>
reject(new DOMException("The request was aborted", "AbortError"));
if (signal?.aborted === true) {
aborted();
return;
}
signal?.addEventListener("abort", aborted, { once: true });
});
const mockedHttpBoundary = ({
serverName,
tools,
call,
}: {
readonly serverName: string;
readonly tools: readonly {
readonly name: string;
readonly inputSchema: object;
}[];
readonly call: (
request: MockedIdaHttpRequest,
signal: AbortSignal | null | undefined,
) => Response | Promise<Response>;
}): void => {
vi.stubGlobal(
"fetch",
vi.fn(async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
const message = JSON.parse(String(init?.body ?? "")) as {
readonly id?: string | number;
readonly method: string;
readonly params?: Record<string, unknown>;
};
const json = (value: unknown) =>
new Response(JSON.stringify(value), {
headers: { "Content-Type": "application/json" },
});
if (message.method === "initialize")
return json({
const request = JSON.parse(
String(init?.body ?? ""),
) as MockedIdaHttpRequest;
if (request.method === "initialize")
return jsonRpcResponse({
jsonrpc: "2.0",
id: message.id,
id: request.id,
result: {
protocolVersion: message.params?.protocolVersion,
protocolVersion: request.params?.protocolVersion,
capabilities: { tools: {} },
serverInfo: { name: "ida-timeout-fixture", version: "1" },
serverInfo: { name: serverName, version: "1" },
},
});
if (
message.method === "notifications/initialized" ||
message.method === "notifications/cancelled"
request.method === "notifications/initialized" ||
request.method === "notifications/cancelled"
)
return new Response(null, { status: 202 });
if (message.method === "tools/list")
return json({
if (request.method === "tools/list")
return jsonRpcResponse({
jsonrpc: "2.0",
id: message.id,
result: { tools: [] },
id: request.id,
result: { tools },
});
if (message.method !== "tools/call")
throw new Error(`Unexpected fixture request: ${message.method}`);
pendingCall();
return new Promise<Response>((_resolve, reject) => {
const signal = init?.signal;
if (signal?.aborted === true) {
reject(signal.reason);
return;
}
signal?.addEventListener(
"abort",
() =>
reject(new DOMException("The request was aborted", "AbortError")),
{ once: true },
);
});
if (request.method === "tools/call") return call(request, init?.signal);
throw new Error(`Unexpected fixture request: ${request.method}`);
}),
);
};
it("preserves the SDK request timeout as a typed IDA timeout", async () => {
const timeoutMs = 1000;
const requestTimeoutMs = 40;
const pendingCall = vi.fn();
mockedHttpBoundary({
serverName: "ida-timeout-fixture",
tools: [],
call: (_request, signal) => {
pendingCall();
return stalledResponse(signal);
},
});
const connection = createIdaMcpConnection(
{
url: "http://127.0.0.1:12345/mcp",
@@ -210,11 +239,14 @@ it("preserves the SDK request timeout as a typed IDA timeout", async () => {
try {
await expect(connection.connect()).resolves.toEqual([]);
const failure = await connection
.call("decompile", {})
.call("decompile", {}, { timeoutMs: requestTimeoutMs })
.catch((cause: unknown) => cause);
expect(pendingCall).toHaveBeenCalledOnce();
expect(failure).toBeInstanceOf(AnalysisTimeoutError);
expect(failure).toMatchObject({ operation: "decompile", timeoutMs });
expect(failure).toMatchObject({
operation: "decompile",
timeoutMs: requestTimeoutMs,
});
if (!(failure instanceof AnalysisTimeoutError))
throw new Error("Expected a typed IDA request timeout");
expect(failure.cause).toBeInstanceOf(Error);
@@ -224,6 +256,75 @@ it("preserves the SDK request timeout as a typed IDA timeout", async () => {
}
});
it("bounds a stalled health poll by the remaining readiness deadline", async () => {
const timeoutMs = 800;
const pendingCall = vi.fn();
let clockNow = 0;
const clock = {
now: () => clockNow,
delay: async (ms: number) => {
clockNow += ms;
},
};
let pendingCalls = 0;
const busy = {
status: "busy",
busy_tool: "config_json_get",
busy_sec: 1,
queued_calls: 0,
};
mockedHttpBoundary({
serverName: "ida-health-deadline-fixture",
tools: [{ name: "server_health", inputSchema: { type: "object" } }],
call: (request, signal) => {
pendingCalls += 1;
if (pendingCalls === 1) {
clockNow += 450;
const result = {
content: [{ type: "text", text: JSON.stringify(busy) }],
structuredContent: busy,
};
return jsonRpcResponse({ jsonrpc: "2.0", id: request.id, result });
}
return stalledResponse(signal);
},
});
const connection = createIdaMcpConnection(
{
url: "http://127.0.0.1:12345/mcp",
headers: {},
mode: "headless",
timeoutMs,
},
{},
);
try {
await connection.connect();
const failure = await readSettledHealth(connection, {
database: "fixture-db",
timeoutMs,
clock,
}).catch((cause: unknown) => cause);
expect(pendingCalls).toBe(2);
expect(failure).toBeInstanceOf(AnalysisTimeoutError);
expect(failure).toMatchObject({
operation: "server_health: headless worker still busy in config_json_get",
timeoutMs,
cause: busy,
diagnostics: {
health_request_operation: "server_health",
health_request_timeout_ms: 250,
},
});
if (!(failure instanceof AnalysisTimeoutError))
throw new Error("Expected the readiness deadline to expire");
expect(failure.capturedOutput).toBeDefined();
} finally {
await connection.close();
}
});
const inspectRetainedError = (error: unknown): string =>
inspect(error, { depth: null, maxStringLength: null, breakLength: Infinity });
+12 -4
View File
@@ -35,6 +35,7 @@ export interface IdaMcpConnection {
call(
name: string,
args: Readonly<Record<string, JsonValue>>,
options?: { readonly timeoutMs?: number | undefined },
): Promise<JsonValue>;
readResource?(uri: string): Promise<JsonValue>;
serverInfo(): JsonValue;
@@ -349,6 +350,7 @@ export const createIdaMcpConnection = (
const transportFailure = (
cause: unknown,
operation = "ida_mcp_response",
requestTimeoutMs = config.timeoutMs,
): never => {
const bufferLimit =
cause instanceof Error
@@ -392,7 +394,7 @@ export const createIdaMcpConnection = (
stderr_bytes: stderrBytes,
};
if (cause instanceof SdkError && cause.code === SdkErrorCode.RequestTimeout)
throw new AnalysisTimeoutError(operation, config.timeoutMs, {
throw new AnalysisTimeoutError(operation, requestTimeoutMs, {
cause: failure,
capturedOutput,
});
@@ -458,11 +460,17 @@ export const createIdaMcpConnection = (
.catch((cause: unknown) => transportFailure(cause, "ida_mcp_catalog"));
return catalog.tools.map(({ name }) => name);
},
async call(name, args) {
async call(name, args, options) {
const requestTimeoutMs = Math.min(
config.timeoutMs,
options?.timeoutMs ?? config.timeoutMs,
);
return decodeIdaToolResult(
await client
.callTool({ name, arguments: args }, { timeout: config.timeoutMs })
.catch((cause: unknown) => transportFailure(cause, name)),
.callTool({ name, arguments: args }, { timeout: requestTimeoutMs })
.catch((cause: unknown) =>
transportFailure(cause, name, requestTimeoutMs),
),
config.protocol,
redactor,
);
+6 -1
View File
@@ -24,6 +24,7 @@ export interface McpToolTimeoutInputs {
readonly ghidraStartupTimeoutMs: number;
readonly hopperStartupTimeoutMs: number;
readonly jebMcpTimeoutMs?: number;
readonly providerRequestTimeoutMs?: number;
}
/** Allow for known startup phases and caller-selected upstream request budgets. */
@@ -40,7 +41,11 @@ export const mcpProviderDeadlineAllowanceMs = (
inputs.ghidraStartupTimeoutMs,
inputs.hopperStartupTimeoutMs,
);
return Math.max(selectedTimeout, inputs.jebMcpTimeoutMs ?? 0);
return Math.max(
selectedTimeout,
inputs.jebMcpTimeoutMs ?? 0,
inputs.providerRequestTimeoutMs ?? 0,
);
};
/** Add operation headroom to the parsed provider deadline allowance. */
@@ -322,6 +322,142 @@ cliTest(
120_000,
);
for (const [client, provider] of [
["codex", "ida"],
["kimi", "ida"],
["kimi", "auto"],
] as const)
cliTest(
`public ${client} setup and doctor use the effective IDA registration request budget with ${provider} selection`,
async ({ cli }) => {
const home = await createTestTempDirectory(
"rea-ida-registration-budget-",
);
const clientHome = join(home, client);
await mkdir(clientHome, { recursive: true });
const idaConfig = join(home, "ida.json");
const writeRegistration = (timeoutMs: number) =>
writeFile(
idaConfig,
JSON.stringify({
url: "http://127.0.0.1:12345/mcp",
headers: { Authorization: "Bearer fixture-credential" },
timeoutMs,
}),
);
await writeRegistration(1_800_001);
const environment = {
HOME: home,
USERPROFILE: home,
XDG_CONFIG_HOME: join(home, ".config"),
CODEX_HOME: clientHome,
KIMI_CODE_HOME: clientHome,
HOPPER_LAUNCHER_PATH: join(home, "missing-hopper"),
};
const setupArguments = [
"setup",
"--client",
client,
"--skill=false",
"--yes",
"--json",
];
const configured = await cli.run({
arguments: setupArguments,
cwd: home,
environment: {
...environment,
REA_ANALYSIS_PROVIDER: provider,
REA_IDA_MCP_CONFIG: idaConfig,
},
});
expect(configured.exitCode, configured.stdout).toBe(0);
const config = join(
clientHome,
client === "codex" ? "config.toml" : "mcp.json",
);
const text = await readFile(config, "utf8");
expect(
effectiveClientServer(
parseClientConfiguration(text, client === "codex" ? "toml" : "kimi"),
"rea",
),
).toMatchObject({
...(client === "codex"
? { tool_timeout_sec: 1921 }
: { toolTimeoutMs: 1_920_001 }),
env: { REA_ANALYSIS_PROVIDER: provider, REA_IDA_MCP_CONFIG: idaConfig },
});
expect(text).not.toContain("fixture-credential");
const run = (arguments_: readonly string[]) =>
cli.run({ arguments: arguments_, cwd: home, environment });
const healthy = await run(["doctor", "--client", client, "--json"]);
expect(healthy.exitCode, healthy.stdout).toBe(0);
expect(JSON.stringify(healthy.json)).not.toContain('"advisory"');
// Only the stored client's provider path identifies this changed budget;
// the doctor/setup process has no IDA configuration in its environment.
await writeRegistration(2_400_001);
const doctor = await run(["doctor", "--client", client, "--json"]);
expect(doctor.exitCode, doctor.stdout).toBe(0);
expect(doctor.json).toMatchObject({
identity: {
registrations: expect.arrayContaining([
expect.objectContaining({
client,
state: "aligned",
advisory: expect.stringContaining(
client === "codex"
? "tool_timeout_sec = 2521 seconds"
: "toolTimeoutMs = 2520001 ms",
),
}),
]),
},
});
const repeated = await run(setupArguments);
expect(repeated.exitCode, repeated.stdout).toBe(0);
expect(repeated.json).toMatchObject({
status: "ready",
plannedActions: [],
appliedActions: [],
});
expect(await readFile(config, "utf8")).toBe(text);
await expect(access(`${config}.rea.backup`)).rejects.toMatchObject({
code: "ENOENT",
});
if (provider === "ida") {
await writeRegistration(0);
const invalidDoctor = await run([
"doctor",
"--client",
client,
"--json",
]);
expect(invalidDoctor.exitCode).toBe(1);
expect(invalidDoctor.json).toMatchObject({
identity: {
registrations: expect.arrayContaining([
expect.objectContaining({
client,
state: "invalid",
remediation: expect.stringContaining("REA_IDA_MCP_CONFIG"),
}),
]),
},
});
const invalidSetup = await run(setupArguments);
expect(invalidSetup.exitCode).toBe(1);
expect(invalidSetup.stdout).toContain("REA_IDA_MCP_CONFIG");
expect(await readFile(config, "utf8")).toBe(text);
await expect(access(`${config}.rea.backup`)).rejects.toMatchObject({
code: "ENOENT",
});
}
},
120_000,
);
for (const invalidSetting of [
{ setting: "REA_MCP_INPUT_SCHEMA_PROFILE", value: "unknown" },
{ setting: "REA_GHIDRA_STARTUP_TIMEOUT_MS", value: "0" },
@@ -470,14 +470,14 @@ const verifyWindowsSnapshotAdmission = async (
};
it.each([
{ tool: "binary_overview", arguments: {}, priorRevision: 3 },
{ tool: "binary_overview", arguments: {}, priorRevision: 4 },
{
tool: "inspect_native_api",
arguments: { procedure: "0x1000" },
priorRevision: undefined,
priorRevision: 1,
},
] as const)(
"refreshes pre-coverage $tool Evidence and replays the current revision",
"refreshes unattributed $tool Evidence and replays the current revision",
async (scenario) => {
const fixture = await createSnapshotReplayFixture("rea-prior-coverage-");
const limitation = "Original PDB names may be missing.";
@@ -523,7 +523,9 @@ it.each([
options,
),
);
expect(refreshed.limitations).toContain(limitation);
expect(refreshed.limitations).toEqual(
expect.arrayContaining(first.limitations),
);
expect(refreshed.analysis_profile?.digest).not.toBe(priorProfile.digest);
expect(refreshed.normalized_result).toEqual(first.normalized_result);
const starts = [...fixture.starts];
@@ -124,8 +124,8 @@ describe("enhanced MCP graph caveats", () => {
limitations: [
"Artifact identity is unavailable for this observation.",
"Derived by an REA composed workflow.",
caveat,
leafCaveat,
`Fixture analysis provider (fixture 1) procedure_callees: ${caveat}`,
`Fixture analysis provider (fixture 1) procedure_callees: ${leafCaveat}`,
],
normalized_result: {
graph: {
@@ -153,9 +153,6 @@ describe("enhanced MCP graph caveats", () => {
const normalized = JSON.stringify(evidence.normalized_result);
for (const item of [caveat, leafCaveat]) {
expect(normalized.split(item)).toHaveLength(2);
expect(
evidence.limitations.filter((limit) => limit === item),
).toHaveLength(1);
}
const trace = await client.callTool({
@@ -172,7 +169,11 @@ describe("enhanced MCP graph caveats", () => {
});
const traceEvidence = parseEvidence(trace.structuredContent);
expect(
traceEvidence.limitations.filter((item) => item === caveat),
traceEvidence.limitations.filter(
(item) =>
item ===
`Fixture analysis provider (fixture 1) procedure_callees: ${caveat}`,
),
).toHaveLength(1);
expect(JSON.stringify(traceEvidence)).not.toContain(leafCaveat);
});
@@ -31,7 +31,7 @@ it("retains provider coverage caveats in a composed MCP overview without repeate
expect(evidence.limitations).toEqual([
"Artifact identity is unavailable for this observation.",
"Derived by an REA composed workflow.",
limitation,
`Fixture (fixture 1) current_document, list_procedures, list_segments, list_strings: ${limitation}`,
]);
expect(evidence.normalized_result).toMatchObject({
document: "fixture",
+60 -2
View File
@@ -4,7 +4,10 @@ import { EnhancedTools } from "../../../src/application/EnhancedTools.js";
import { connectGhidraMcp, sessionEvidence } from "./ghidraMcpHarness.js";
import { functionDossierSchema } from "../../../src/domain/hopperValues.js";
import { functionComparisonResultSchema } from "../../../src/domain/functionComparisonSchemas.js";
import { ghidraFunctionDossier } from "../../../src/domain/ghidraValues.fixture.js";
import {
ghidraFunctionDossier,
ghidraFunctionIdentity,
} from "../../../src/domain/ghidraValues.fixture.js";
import {
jsonValueSchema,
type JsonValue,
@@ -186,7 +189,7 @@ it("retains terminal-call qualifications across function views and snapshot repl
expect(evidence.limitations, name).toContain(limitation);
expect(evidence.analysis_profile?.parameters).toMatchObject({
function_boundary_observations:
"ghidra-function-boundary-limitations-v3",
"ghidra-function-boundary-limitations-v4",
});
const replay = await harness.session.execute(name, evidence.parameters);
if (!replay.ok) throw replay.error;
@@ -202,6 +205,61 @@ it("retains terminal-call qualifications across function views and snapshot repl
}
});
it("retains handler-region limitations on incoming and outgoing reference Evidence", async () => {
const limitation =
"Ghidra Listing shows a Borland/Delphi-style SEH frame registration at 0x40108f (push 0x4010c0 followed by fs-segment stack chaining); handler 0x4010c0 lies outside this procedure's body, so callees, strings, and cross-references may omit the handler region.";
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
const harness = await connectGhidraMcp(
"ghidra-seh-reference-evidence",
(operation, input) => {
if (operation !== "procedure_references")
throw new Error(`Unexpected operation: ${operation}`);
return Promise.resolve(
ok({
value: jsonValueSchema.parse({
procedure: ghidraFunctionIdentity(),
direction: input.direction ?? "outgoing",
references: [],
reference_kinds_available: true,
unresolved_calls: [],
}),
limitations: [limitation],
}),
);
},
);
try {
for (const direction of ["incoming", "outgoing"] as const) {
const reply = await harness.mcp.callTool({
name: "procedure_references",
arguments: { procedure: dossier.procedure.address, direction },
});
expect(reply.isError).not.toBe(true);
const evidence = sessionEvidence(
harness.session,
reply.structuredContent,
);
expect(evidence.normalized_result).toMatchObject({ direction });
expect(evidence.raw_result).toMatchObject({ limitations: [limitation] });
expect(evidence.limitations).toContain(limitation);
const replay = await harness.session.execute(
"procedure_references",
evidence.parameters,
);
if (!replay.ok) throw replay.error;
expect(replay.value.limitations).toContain(limitation);
}
expect(harness.calls).toEqual([
"procedure_references",
"procedure_references",
"procedure_references",
"procedure_references",
]);
} finally {
await harness.close();
}
});
it("rejects contradictory annotation readback across the provider and MCP boundaries", async () => {
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
const annotations = {
+1
View File
@@ -274,6 +274,7 @@ const functionObservationOperations: ReadonlySet<GhidraOperation> = new Set([
"procedure_callers",
"procedure_info",
"procedure_pseudo_code",
"procedure_references",
"read_function_instructions",
]);
+5 -1
View File
@@ -409,7 +409,11 @@ describe("active-target availability over MCP", () => {
const session = createTestBinarySession(
availabilityProvider(["inspect_macho", "inspect_artifact"]),
);
const server = createServer({ kind: "session", session });
// Availability does not depend on compiling every advertised output schema.
const server = createServer(
{ kind: "session", session },
{ environment: { REA_MCP_OUTPUT_SCHEMA_PROFILE: "off" } },
);
const client = new Client({ name: "availability-test", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
@@ -42,7 +42,10 @@ it("retains provider coverage caveats in a composed CLI result and closes the se
{},
);
expect(output).toMatchObject({
limitations: ["Derived by an REA composed workflow.", limitation],
limitations: [
"Derived by an REA composed workflow.",
`Fixture (fixture 1) current_document, list_procedures, list_segments, list_strings: ${limitation}`,
],
normalized_result: {
document: "fixture",
segment_count: 0,
+19 -6
View File
@@ -3,11 +3,11 @@
#include "../../native/windows/src/authority.hpp"
#include <atomic>
static BOOL inspectionDisposition(HANDLE handle, FILE_INFO_BY_HANDLE_CLASS kind,
LPVOID value, DWORD size) {
// A real NTFS probe is created normally. Fail its first disposition call so
// the verifier can observe ownership after failure, independently of host
// filter drivers or the intermittent error reported in issue #1701.
static BOOL fixtureDisposition(HANDLE handle, FILE_INFO_BY_HANDLE_CLASS kind,
LPVOID value, DWORD size) {
// A real NTFS runtime is created normally. Fail its first rollback
// disposition so the verifier can observe the owner after post-create
// validation fails, independent of host filesystem behavior.
static std::atomic_flag failed = ATOMIC_FLAG_INIT;
if (kind == FileDispositionInfo && !failed.test_and_set()) {
SetLastError(ERROR_ACCESS_DENIED);
@@ -16,5 +16,18 @@ static BOOL inspectionDisposition(HANDLE handle, FILE_INFO_BY_HANDLE_CLASS kind,
return SetFileInformationByHandle(handle, kind, value, size);
}
#define SetFileInformationByHandle inspectionDisposition
static DWORD inspectionSecurityInfo(HANDLE handle, SE_OBJECT_TYPE kind,
SECURITY_INFORMATION information,
PSID* owner, PSID* group, PACL* dacl, PACL* sacl,
PSECURITY_DESCRIPTOR* descriptor) {
// Force validation to fail only after FILE_CREATE has allocated the root.
static std::atomic_flag failed = ATOMIC_FLAG_INIT;
wchar_t enabled[2];
if (GetEnvironmentVariableW(L"REA_NATIVE_CREATION_FAILURE", enabled, 2) > 0 &&
!failed.test_and_set()) return ERROR_ACCESS_DENIED;
return GetSecurityInfo(handle, kind, information, owner, group, dacl, sacl, descriptor);
}
#define SetFileInformationByHandle fixtureDisposition
#define GetSecurityInfo inspectionSecurityInfo
#include "../../native/windows/src/filesystem.cc"