Compare commits

..
Author SHA1 Message Date
Kaoru0822andClaude Opus 5.5 54e89bd5c8 perf(managed): add retained member summary and pages
inspect_managed_members always returned the assembly-wide member_refs, so
one selected or unmatched type produced megabytes, and an unselected
ordinary assembly exceeded the MCP response budget. Add opt-in
detail: "summary" that retains the complete Evidence and returns identity,
selection with unmatched names, coverage, user-string coverage, limitations
and per-collection counts. Page original rows through inspect_analysis_view
with kind managed-members; each page names its collection so no row schema
is chosen by union order. Complete delivery stays the default.

Refs #3010

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 00:40:46 +00:00
33 changed files with 710 additions and 640 deletions
@@ -2,7 +2,7 @@
name: reverse-engineer-anything
description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis.
metadata:
version: "42"
version: "41"
---
# REA
@@ -104,10 +104,6 @@ explicit path or endpoint and do not need it.
consult the NativeAOT workflow in `references/native-and-artifacts.md` for
optional metadata recovery and its supported host/layout boundary.
- User-owned browser page already open: `list_browser_targets`.
- Browser action the agent must perform (click, type, navigate):
`capture_browser_scenario` with declared actions. See
[runtime observation](references/runtime-observation.md) for connect/launch
and the focused follow-up browser tools.
- Fiddler Everywhere traffic or a native `.saz` capture: read the
[Fiddler workflow](references/runtime-observation.md#fiddler-everywhere-traffic-debugging).
Inspect saved SAZ with `inspect_web_network_capture` and `format: "saz"`;
@@ -327,8 +323,7 @@ as complete while required questions remain open.
[references/javascript-applications.md](references/javascript-applications.md)
- Android APK declarations, classes, methods, and static references:
[references/android-applications.md](references/android-applications.md)
- Browser/Electron observation (passive and scenario) and static/runtime
reconciliation:
- Passive browser/Electron observation and static/runtime reconciliation:
[references/runtime-observation.md](references/runtime-observation.md)
- Evidence paging, comparisons, residual unknowns, and verification:
[references/evidence-workflows.md](references/evidence-workflows.md)
@@ -169,6 +169,14 @@ repeat parsing. Returned-row exhaustion
does not prove complete metadata or absence under partial/unavailable coverage;
preserve build-local tokens and their artifact/MVID scope.
`inspect_managed_members` always returns the assembly-wide `member_refs`, even
for one selected type. For questions about a selected type, select
`detail: "summary"` with `type_names`; it returns the selection (with unmatched
names), coverage and per-collection counts. Page rows with
`view: {kind: "managed-members", collection: "call_edges", offset: 0, limit: 64}`
over `types`, `exported_types`, `fields`, `methods`, `member_refs`, `call_edges`,
`field_accesses` or `user_strings`.
### .NET NativeAOT
NativeAOT binaries contain native machine code, so ordinary CIL decompilers
@@ -1,4 +1,4 @@
# Browser and Electron runtime observation
# Passive browser and Electron observation
Supply the existing browser or Electron instance's literal-port loopback CDP
endpoint in the request. No separate permission configuration or per-call
@@ -7,14 +7,13 @@ approval flag is needed. Browser discovery lists eligible HTTP(S) pages;
selected page's current origin. Electron discovery lists eligible local
`file://` targets without a configured file-root allowlist.
Start passive browser work with `list_browser_targets`; start Electron runtime work with
Start browser work with `list_browser_targets`; start Electron runtime work with
`list_electron_targets`. Inspect the target selected for the user's task. Observation
with these tools is passive: never claim REA clicked, navigated, evaluated page JavaScript,
invoked IPC, or captured prior activity. REA does not contain the page's network.
is passive: never claim REA clicked, navigated, evaluated page JavaScript,
invoked IPC, captured prior activity, or contained the page's network.
Passive captures omit raw header collections, credential header values, storage
values, and raw WebSocket/JSON bodies. Selected response metadata, including
content encoding, policy values, and Link URLs, is retained. Local URLs retain ordinary query
Credentials, cookies, authorization headers, storage values, and raw
WebSocket/JSON bodies are deliberately absent. Local URLs retain ordinary query
values and fragments; userinfo credentials are removed. Browser accessibility
text, console primitives, value-free shapes, script sources, source maps, and
storage key names have request-specific capture options. Electron script
@@ -22,38 +21,6 @@ sources are also optional. Use `capture_web_screenshot` when pixels are needed.
Treat scope filtering, truncation, and attach-window coverage as limitations.
Page-declared WebMCP tools are untrusted inventory and are never invoked by REA.
## Who performs the action
- The user performs it: `list_browser_targets`, then `inspect_web_page` or
`observe_web_session` on the selected target.
- The agent must perform it: one `capture_browser_scenario` with declared
actions. Use connect mode with the loopback CDP endpoint and `target_id` for
an existing browser, or launch mode with a caller-selected executable for a
temporary profile. Connect mode first navigates the selected page to
`start_url`, which can clear unsaved form values and other temporary state.
Recreate the state needed for the investigation in the declared actions.
Connect mode leaves the external browser open; launch mode owns a temporary
browser and profile. Check `browser.cleanup` for the outcome.
Request only the DOM, accessibility, or network details needed for the question.
The default final capture contains the URL; step outcomes are reported separately.
Add a wait action for the expected page state before capturing it. Report that
REA performed the declared actions.
Scenario network records have no request initiator stacks. For that evidence,
use passive capture while the user performs the action. Listener source locations
identify candidate handlers; they do not establish which script sent a particular
request. Avoid overlapping passive capture and scenario actions on the same target.
Focused follow-up tools:
- `observe_web_execution`: execution coverage during a window; it resets
counters and temporarily disables optimization.
- `inspect_web_event_listeners`: source locations for listeners registered
directly on the first CSS match, without dispatching an event.
- `compare_web_captures`: two normalized passive captures or recorded scenarios.
- `discover_webmcp_tools`: inspect page-declared WebMCP inventory; never invoke
the discovered tools.
Use `reconcile_javascript_runtime` only after static application Evidence and
passive browser/Electron Evidence exist. Prefer captured-source digest identity;
report path/digest disagreement as a mismatch and preserve ambiguity. Explicit
+1 -5
View File
@@ -294,11 +294,7 @@ the first native server into an empty table, it copies the fallback's
`mcpServers` entries into the native table so they keep loading, and leaves
the fallback file untouched. Existing REA environment overrides are preserved
when refreshing its command. A malformed or unreadable fallback file fails setup instead of
being silently suppressed; repair or remove it and rerun. Uninstall removes an
REA-owned `rea` entry from both the native table and the fallback, backing up
each changed file, so an emptied native table cannot load a leftover fallback
registration; other fallback servers, settings and a foreign `rea` entry are
kept. ZCode disables a
being silently suppressed; repair or remove it and rerun. ZCode disables a
server with `enable: false` on its entry; doctor reports such a registration
as stale, and rerunning setup removes the flag to repair it. Native configuration
uses strict JSON; setup refuses comments, trailing commas and a BOM rather than
-144
View File
@@ -129,150 +129,6 @@ Setup pins package-runner MCP registrations to the exact installed REA version,
installs the matching skill and on-demand references in the same plan, and adds
`startup_timeout_sec = 30` for Codex and Grok Build.
## Limit REA to one project or agent profile
Setup registers REA for the whole client. For Claude Code it writes
`mcpServers.rea` in `~/.claude.json` and installs the bundled skill under
`~/.claude/skills`, so every project that Claude Code opens can call REA. To
offer REA's tools only where they're wanted, use one of the routes below.
Both routes control which sessions discover REA's MCP server and skill. They
leave shell commands, filesystem access and network access available. An agent
under the same OS account can run `rea` from `PATH` or `npx` and read other
profiles and REA output. For clean-room separation, use an implementation
environment with filesystem and network restrictions that prevent reading or
fetching REA, the target and analysis output, including through shared
installations and package registries. A separate account or container must
enforce those restrictions.
For Claude Code, check existing default-profile state before choosing either
route. Setup is additive: a previous user-level REA registration and personal
skill remain active in other projects. Back up the default configuration and
REA skill first, then, with `CLAUDE_CONFIG_DIR` unset, remove the REA user
registration with `claude mcp remove --scope user rea` and remove the known
REA-installed `~/.claude/skills/reverse-engineer-anything` directory. Preserve
unrelated registrations and skills. `rea uninstall` applies to other clients
as well, so it is broader than this Claude-only migration.
**A separate Claude Code profile.** Claude Code keeps settings, skills and session
history under `CLAUDE_CONFIG_DIR`. Setup follows the same
variable: it writes the registration to `$CLAUDE_CONFIG_DIR/.claude.json` and
the skill to `$CLAUDE_CONFIG_DIR/skills`.
```bash
CLAUDE_CONFIG_DIR=~/.claude-rea npx rea-agents setup --client claude_code
CLAUDE_CONFIG_DIR=~/.claude-rea claude
```
A new profile may need authentication. Console sign-ins without an API key are
stored outside `CLAUDE_CONFIG_DIR`; see
[Claude Code authentication](https://code.claude.com/docs/en/authentication#log-in-with-multiple-accounts).
Sessions started without the variable use the default profile's registration,
skill and history.
Inline assignments apply only to that command. Keep the same prefix for every
profile check, setup rerun, update, or removal; bare commands use the current
environment and can otherwise inspect or recreate default-profile state:
```bash
CLAUDE_CONFIG_DIR=~/.claude-rea claude mcp list
CLAUDE_CONFIG_DIR=~/.claude-rea npx rea-agents doctor --client claude_code --skill --json
CLAUDE_CONFIG_DIR=~/.claude-rea npx -y rea-agents@latest setup --client claude_code --dry-run
```
After reviewing an update plan, rerun that same prefixed setup command without
`--dry-run` and approve its exact changes. Profile removal likewise needs the
prefix on `claude mcp remove --scope user rea` and any `rea uninstall` command;
keep the broader client scope of uninstall in mind. Restart the selected
profile after changing its pinned registration.
`claude mcp remove` removes only the registration. To remove REA from this
profile without running the broader uninstall flow, first back up the profile's
REA-managed skill directory, then remove
`$CLAUDE_CONFIG_DIR/skills/reverse-engineer-anything` as well. Keep other skills
in the profile intact.
The commands above use POSIX shell assignment syntax. In native Windows
PowerShell, select the profile for the whole setup/launch/check session and
restore the previous selection afterward:
```powershell
$previousClaudeConfigDir = $env:CLAUDE_CONFIG_DIR
try {
$env:CLAUDE_CONFIG_DIR = Join-Path $HOME ".claude-rea"
npx rea-agents setup --client claude_code
claude mcp list
npx rea-agents doctor --client claude_code --skill --json
npx -y rea-agents@latest setup --client claude_code --dry-run
# After reviewing the plan, repeat setup without --dry-run and approve it.
claude
# Profile removal, when intended, also belongs inside this selected scope:
# claude mcp remove --scope user rea
} finally {
if ($null -eq $previousClaudeConfigDir) {
Remove-Item Env:CLAUDE_CONFIG_DIR -ErrorAction SilentlyContinue
} else {
$env:CLAUDE_CONFIG_DIR = $previousClaudeConfigDir
}
}
```
Use PowerShell for these Windows examples rather than pasting POSIX assignments
into cmd.exe. Keep profile updates, readiness checks and removal inside the
same selected environment; the `finally` block restores the previous profile
or unsets the variable when it was absent.
**One project.** Install without configuring Claude Code: use the curl
installer's `--no-setup`, or run setup only for other clients. Then register
REA from the project directory at Claude Code's local scope.
This route uses the default Claude profile. Keep `CLAUDE_CONFIG_DIR` unset for
the entire local lifecycle: registration, backup, listing, version replacement
and removal. In POSIX shells run `unset CLAUDE_CONFIG_DIR`; in PowerShell run
`Remove-Item Env:CLAUDE_CONFIG_DIR -ErrorAction SilentlyContinue` before these
commands. Record an existing selection first if you intend to restore it after
finishing. The configuration path below is the default profile's
`~/.claude.json`; a different active profile uses its own config root instead.
Register with the variable unset:
<!-- x-release-please-start-version -->
```bash
claude mcp add --scope local rea -- npx -y rea-agents@6.4.0 mcp
```
On native Windows, use the same project directory and wrap the package-runner
shim so the stdio client can launch it:
```powershell
claude mcp add --scope local rea -- cmd.exe /d /c npx -y rea-agents@6.4.0 mcp
```
<!-- x-release-please-end -->
Local scope stores the entry under that directory's path in `~/.claude.json`,
so other projects don't load it. This route doesn't install the bundled skill.
Avoid `--scope project`, which writes `.mcp.json` into the repository where it
can be committed or copied. `rea doctor` and `rea uninstall` read only the
user-level entry, so they don't report or remove a local-scope registration;
remove it with `claude mcp remove --scope local rea` from the same directory.
`rea update` doesn't refresh it either, so it stays on the version it names.
To move it to a newer release, first back up the project entry in
`~/.claude.json` and record its environment and other metadata. In particular,
preserve caller-selected overrides such as `REA_MCP_INPUT_SCHEMA_PROFILE` and
`REA_MCP_MAX_RESPONSE_BYTES`. Run `claude mcp remove --scope local rea` from the
same directory, then recreate it with the platform-appropriate command above
and the new version. Reapply every recorded environment value with Claude
Code's `mcp add --env NAME=value` options and restore any other retained entry
metadata before restarting the project session. Removal and re-addition do not
preserve these values automatically. Running setup for Claude Code instead
would add a user-level entry for every project.
Check the separate profile with the prefixed `claude mcp list` above and compare
with the default profile with `CLAUDE_CONFIG_DIR` unset. For local scope, run
`claude mcp list` from the selected project and again from another project.
## Check readiness for your task
Run `rea doctor` when you need diagnosis. It reads host prerequisites and agent
+34
View File
@@ -81,6 +81,40 @@ including the existing `inspect-analysis-view` CLI. Session record lifetime and
export/import semantics remain unchanged. A server without Evidence retention
rejects selected summary before analysis; complete delivery remains available.
### Member summary and retained member rows
Complete `inspect_managed_members` delivery always includes the assembly-wide
`member_refs` and `exported_types`, which `type_names` does not narrow, so even
one selected or unmatched type can return megabytes. Select
`{"path":"/absolute/path/Assembly.dll","type_names":["Namespace.Type"],"detail":"summary"}`
to keep the complete source in the session and receive identity, metadata
status, the selection with its unmatched names, coverage/issues, the separate
user-string coverage, limitations and per-collection counts. Then page original
rows with view kind `managed-members`:
```json
{
"source": {
"kind": "retained-evidence",
"evidence_id": "<parent_evidence_id>"
},
"view": {
"kind": "managed-members",
"collection": "call_edges",
"offset": 0,
"limit": 64
}
}
```
Collections are `types`, `exported_types`, `fields`, `methods`, `member_refs`,
`call_edges`, `field_accesses` and `user_strings` (the literals). Each page names
its collection, retains original rows, order, tokens, signatures and body facts,
and carries the source selection and coverage; `user_strings` pages carry the
literal pool's own coverage. Older Evidence that never examined exported types
or user strings reports a null count and refuses those pages rather than
returning an empty one.
## Shipped scope
The canonical parser admits PE/CLI bytes and their metadata/CIL without loading
+25 -22
View File
@@ -20,7 +20,7 @@
**[웹사이트](https://rea.tools/ko/) · [가이드](https://rea.tools/ko/guides/) · [사례](https://rea.tools/ko/showcase/)**
[빠른 시작](#빠른-시작) · [REA의 작동 방식](#rea의-작동-방식) · [분석할 수 있는 대상](#분석할-수-있는-대상) · [사례](#사례) · [자주 묻는 질문](#자주-묻는-질문) · [문서](#문서)
[빠른 시작](#빠른-시작) · [REA의 작동 방식](#rea의-작동-방식) · [분석할 수 있는 대상](#분석할-수-있는-대상) · [사례](#사례) · [자주 묻는 질문](#자주-묻는-질문) · [GNU objdump 및 Rizin 도구](../reverse-engineering-tools.md) · [문서](#문서)
<code>npx rea-agents setup</code>
@@ -56,7 +56,7 @@
REA는 에이전트를 분석 도구에 연결해 네이티브 바이너리, JavaScript와 Electron 앱, .NET 어셈블리, 웹사이트를 검사할 수 있게 합니다. 터미널에서도 같은 도구를 사용할 수 있습니다. 분석은 로컬에서 실행되며, 결과에는 각 결론의 근거와 한계가 포함됩니다.
설정 과정은 에이전트에 REA를 등록하고 해당 버전에 맞는 워크플로 지침을 설치합니다. 네이티브 분석에는 기존 Hopper, Ghidra 또는 IDA 설치를 사용할 수 있습니다. 승인하면 설정 과정에서 Hopper를 설치할 수도 있습니다. 정적 JavaScript 분석에는 네이티브 분석 엔진이 필요하지 않습니다.
설정 과정은 에이전트에 REA를 등록하고 해당 버전에 맞는 워크플로 지침을 설치합니다. 네이티브 분석에는 기존 Hopper 또는 Ghidra 설치를 사용할 수 있습니다. 승인하면 설정 과정에서 Hopper를 설치할 수도 있습니다. 정적 JavaScript 분석에는 두 엔진 모두 필요하지 않습니다.
> **[REA 웹사이트](https://rea.tools/ko/)**에서 설정 방법, 그림으로 설명한 가이드, 실제 사례를 확인하세요.
@@ -105,14 +105,12 @@ rea --help
REA는 빠르게 바뀌며 새 릴리스에는 버그 수정이 자주 포함됩니다. 설치된 버전을 최신으로 유지하세요.
`npm install --global` 또는 `bun add --global`로 설치한 CLI에서는 다음 명령을 사용합니다.
npm으로 설치한 CLI에서는 다음 명령을 사용합니다.
```bash
rea update
```
Bun 전역 업데이트에는 Bun 1.2.17 이상이 필요합니다. 이전 버전의 Bun을 사용한다면 `rea update`를 실행하기 전에 Bun을 직접 업그레이드하세요.
에이전트 등록과 스킬을 갱신하려면 업데이트 과정에서 출력된 설정 명령을 실행하세요.
`npx`를 사용한다면 다음 명령으로 에이전트 설정을 갱신하세요.
@@ -121,7 +119,7 @@ Bun 전역 업데이트에는 Bun 1.2.17 이상이 필요합니다. 이전 버
npx rea-agents@latest setup
```
설정 변경 사항을 검토하고 에이전트를 다시 시작하세요. 일회성 CLI 작업에는 `npx rea-agents@latest` 뒤에 필요한 명령을 붙여 실행하세요. Bun을 사용한다면 위 명령의 `npx rea-agents@latest`를 `bunx rea-agents@latest`로 바꿔 실행하세요.
설정 변경 사항을 검토하고 에이전트를 다시 시작하세요. 일회성 CLI 작업에는 `npx rea-agents@latest` 뒤에 필요한 명령을 붙여 실행하세요.
## REA의 작동 방식
@@ -141,16 +139,14 @@ REA에는 Node.js 22.x(>=22.19), 24.x(>=24.11) 또는 26+와 npm이 필요합니
| --------------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| 네이티브 바이너리 | 의사 코드, 어셈블리, 문자열, 심볼, 호출, 참조 | Hopper, Ghidra 또는 IDA; [네이티브 분석](https://rea.tools/ko/guides/native/) |
| 오프라인 ELF 레이아웃 | 섹션, 세그먼트, 원본 심볼/재배치 정보, 정적 분석으로 얻은 보안 완화 기법 후보 | Linux x64에서 호출자가 제공하는 pwntools; [바이너리 진단](../binary-diagnostics.md) |
| Go 바이너리 | 내장된 컴파일러 버전, 모듈, `replace` 지시어, 빌드 설정 | REA 내장 ELF/PE/thin Mach-O 정적 검사; Go 설치 불필요 |
| Rust 바이너리 | cargo-auditable이 내장한 크레이트 이름, 버전, 소스, 의존성 관계 | REA 내장 ELF/PE/thin Mach-O 정적 검사; [Rust 바이너리](../rust-binaries.md) |
| WASM 아티팩트 | 유효성 검사, 섹션, import/export의 정확한 WAT 형식, 디코딩된 WAT | 호출자 제공 WABT 1.0.42; `inspect_wasm_artifact` |
| EVM 바이트코드 | 디스패치 선택자, 바이트 오프셋, 추론한 인자와 상태 변경 가능 여부 | Linux x64의 로컬 원시 바이트/16진수 입력 파일; [오프라인 EVM 가이드](../evm-bytecode.md) |
| SQLite 데이터베이스 | 테이블, 열, 인덱스, 뷰, 트리거, 선택한 일반 테이블의 행 | 로컬 데이터베이스/WAL 스냅샷; Node.js 24.15 이상의 24.x 또는 26 이상(네이티브 SQLite 한도 적용) |
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
| 기록된 Linux 크래시 | 원시 note 레코드, 기록된 모든 스레드의 레지스터/시그널, 선택적 매핑 후보 | Linux x64에서 호출자가 제공하는 pwntools; GDB/pwndbg는 선택 사항; [기록된 크래시](../recorded-crashes.md) |
| JavaScript / Electron | 모듈, 임포트, 소스 맵, 라우트, IPC, 네이티브 애드온 관계 | Node.js와 npm; [애플리케이션 분석](https://rea.tools/ko/guides/javascript/) |
| 웹사이트 | 페이지 구조, 스크립트, 네트워크 관찰 결과, 요청한 스크린샷 | Chrome 계열 브라우저; [브라우저 분석](https://rea.tools/ko/guides/browser/) |
| 저장된 네트워크 캡처 | 요청, 응답, 접근 가능한 페이로드, 소스 위치 | HAR 또는 Fiddler Everywhere SAZ; mitmproxy 네이티브 캡처에는 Linux의 mitmdump; [캡처 가이드](../web-network-captures.md) |
| .NET 어셈블리 | 메타데이터, CIL 명령, 선언된 네이티브 의존성, 빌드 비교 | 별도 도구 불필요(정적 검사만); [관리 코드 가이드](../managed-code-analysis.md) |
| 저장된 네트워크 캡처 | 요청, 응답, 접근 가능한 페이로드, 소스 위치 | HAR; mitmproxy 네이티브 캡처에는 Linux의 mitmdump; [캡처 가이드](../web-network-captures.md) |
| 패킷 캡처 | 엔드포인트, DNS, TLS SNI, 프로토콜 분포, HTTP 요청 | 호출자 제공 tshark 및 capinfos; [pcap 가이드](../pcap-analysis.md) |
| .NET 어셈블리 | 메타데이터, CIL 명령, 선언된 네이티브 의존성, 빌드 비교 | 정적 검사; [관리 코드 가이드](../managed-code-analysis.md) |
| Android APK | 매니페스트 선언, 클래스, 디컴파일된 메서드, 참조 | Linux/macOS/Windows x64의 헤드리스 JADX와 전체 JDK; [Android 가이드](../android-analysis.md) |
| Android 기기 | 기기, 패키지, 프로세스, 로그, 덤프, 화면 캡처, APK/파일 전송 | 호출자 제공 adb(에뮬레이터 또는 실제 기기); [ADB 가이드](../adb-device-analysis.md) |
| JEB 프로젝트 | 엔진 기반 프로젝트 유닛, 아티팩트 해시, 타입/메서드 의사코드 | 호출자가 시작하고 MCP를 제공하는 JEB 클라이언트(REA_JEB_MCP_URL로 선택); [JEB 가이드](../jeb-analysis.md) |
@@ -158,18 +154,15 @@ REA에는 Node.js 22.x(>=22.19), 24.x(>=24.11) 또는 26+와 npm이 필요합니
| Flutter 앱 | Dart 스냅샷 해시, ABI별 라이브러리 해시, 엔진 빌드 출처 | 없음 (정적 APK 파싱); [Flutter 가이드](../flutter-analysis.md) |
| 펌웨어 | 영역, 추출 결과, 네이티브 분석으로 전달할 정보 | Linux의 Binwalk / Unblob; [펌웨어 가이드](../firmware-analysis.md) |
| 기록된 패킷 | 선택 필드, 반복 값, 필터 및 디코딩 규칙 | Linux: TShark 4.x + prlimit; [캡처 가이드](../packet-captures.md) |
| 패킷 요약 | 엔드포인트, DNS, TLS SNI, 프로토콜 분포, HTTP 요청 | 호출자 제공 tshark 및 capinfos(Linux에서 검증됨); [pcap 가이드](../pcap-analysis.md) |
| 기록된 논리 신호 | 디코더 주석, 샘플 범위, 진단 정보 | Linux: sigrok-cli 0.7.2 + prlimit; [신호 기록 가이드](../signal-recordings.md) |
| 패키지 및 리소스 | 파일 목록, 다이제스트, plist, Apple 번들 구조, 추출한 리소스 | [아티팩트 및 JavaScript 가이드](../javascript-artifact-reconstruction.md), [Apple 애플리케이션](../apple-application-analysis.md) |
| 프로세스 동작 | 터미널 출력, 상호작용, 종료 및 파일 시스템 관찰 결과, 실행 비교 | 네이티브 PTY를 지원하는 Linux/macOS; [프로세스 캡처](../process-capture.md) |
정적 JavaScript 및 .NET 검사는 제공된 파일을 읽으며 애플리케이션을 실행하지 않습니다. 런타임 캡처는 사용자의 권한으로 선택한 대상을 실행하거나 대상과 상호작용합니다. 각 런타임 가이드에서 그 영향을 설명합니다.
오프라인 네이티브 바이너리 검사에는 호출자가 제공하는 GNU objdump와 Rizin도 사용할 수 있습니다. 제공자 설정, CLI 명령, Evidence의 한계는 [리버스 엔지니어링 도구 가이드](../reverse-engineering-tools.md)를 참고하세요. REA는 objdump와 Rizin을 설치하거나 업데이트하지 않습니다.
<a id="choosing-a-deep-analysis-provider"></a>
지원하는 네이티브 형식과 호스트는 제공자마다 다릅니다. [Hopper 및 Ghidra 설정](../installation.md#hopper), [IDA 가이드](../ida-provider.md)(선택 사항인 네이티브 MCP 브리지 포함), [실험적 Windows Ghidra 지원](../windows-ghidra-p0.md)을 참고하세요. Ghidra는 [16비트 DOS 분석](../ghidra-dos.md)도 지원합니다. 큰 바이너리를 분석할 때는 `REA_GHIDRA_STARTUP_TIMEOUT_MS` 또는 `REA_HOPPER_STARTUP_TIMEOUT_MS`로 제공자의 시작 제한 시간을 늘리세요. 제공자 선택은 [CLI 가이드](../cli.md#choose-a-provider)를 참고하세요. 최신 npm 릴리스 이후 추가된 기능은 [릴리스에서의 사용 가능 여부](../installation.md#released-package-and-main)를 확인하세요.
지원하는 네이티브 형식과 호스트는 제공자마다 다릅니다. [Hopper 및 Ghidra 설정](../installation.md#hopper), [IDA 가이드](../ida-provider.md), [실험적 Windows Ghidra 지원](../windows-ghidra-p0.md)을 참고하세요. Ghidra는 [16비트 DOS 분석](../ghidra-dos.md)도 지원합니다. 제공자 선택은 [CLI 가이드](../cli.md#choose-a-provider)를 참고하세요. 최신 npm 릴리스 이후 추가된 기능은 [릴리스에서의 사용 가능 여부](../installation.md#released-package-and-main)를 확인하세요.
## 사례
@@ -216,7 +209,7 @@ REA로 흥미로운 대상을 분석했다면 알려 주세요. [issue](https://
<details>
<summary><strong>Hopper를 먼저 실행해야 하나요?</strong></summary>
REA는 작업에 필요할 때 Hopper를 실행합니다. macOS에서는 처음 실행할 때 데모 모드를 선택하거나 라이선스를 활성화하라는 대화 상자가 나타날 수 있습니다. 첫 CLI/MCP 작업이나 무인 작업을 실행하기 전에 같은 사용자 세션에서 이 대화 상자를 완료하세요. 필요하면 Hopper를 직접 여세요. [Hopper 실행 및 문제 해결](../installation.md#launcher-paths-and-troubleshooting)을 참고하세요.
REA는 작업에 필요할 때 Hopper를 실행합니다. macOS에서는 처음 실행할 때 데모 모드를 선택하거나 라이선스를 활성화하라는 대화 상자가 나타날 수 있습니다. [Hopper 실행 및 문제 해결](../installation.md#launcher-paths-and-troubleshooting)을 참고하세요.
</details>
@@ -246,7 +239,7 @@ REA는 대상을 로컬에서 분석합니다. 에이전트는 도구 결과를
먼저 업데이트하세요. 최근 릴리스에서 이미 수정되었을 수 있습니다.
`npm install --global` 또는 `bun add --global`로 설치한 CLI에서는 다음 명령을 사용합니다.
npm으로 설치한 CLI에서는 다음 명령을 사용합니다.
```bash
rea update
@@ -293,10 +286,12 @@ REA를 사용하고, 버그를 보고하고, 기능을 요청하고, 빌드를
책임 있는 사용에 관한 안내는 [DISCLAIMER.md](../../DISCLAIMER.md)를 참고하세요.
불법적이거나 허가되지 않은 사용에 대한 프로젝트의 입장은 모든 REA 버전, 배포 채널, 플랫폼에 적용되며, 각 사본에는 해당 라이선스가 그대로 유지됩니다.
REA는 법을 준수하는 연구, 교육, 상호운용성 확보, 소프트웨어 보존, 그리고 본인이 소유하거나 조사 권한을 가진 소프트웨어의 분석을 위한 도구입니다.
The project's position on illegal or unauthorized use covers all REA
versions, distribution channels, and platforms; each copy retains its
applicable licenses.
## 기여
REA에 도움을 보태 주세요! [issue를 등록](https://github.com/morluto/rea/issues)해 버그나 기능을 제안하거나, [pull request를 보내](https://github.com/morluto/rea/pulls) 코드와 문서를 개선할 수 있습니다.
@@ -307,6 +302,14 @@ REA에 도움을 보태 주세요! [issue를 등록](https://github.com/morluto/
[MIT](../../LICENSE)
REA가 작성한 코드는 MIT 라이선스를 따릅니다. 제3자 자료에는 각자의 라이선스와 저작권 고지가 그대로 유지됩니다. 의존성 목록, 보존된 라이선스 원문, 외부 도구 약관은 [제3자 고지](../../THIRD_PARTY_NOTICES.md)를 참고하세요.
REA-authored code is licensed under MIT. Third-party material retains its own
licenses and copyright notices; see [Third-party notices](../../THIRD_PARTY_NOTICES.md)
for the dependency inventory, preserved license texts, and external-tool terms.
IDA Pro / Hex-Rays 디컴파일러, Hopper, JEB 등 상용 도구는 해당 라이선스에 따라 **합법적으로 취득하고 사용 권한을 갖춰야 합니다.** REA는 어떤 도구의 라이선스, 키, 활성화 권한도 부여하지 않습니다. 체험판과 데모는 사용자, 기능, 자동화, 출력 공유 제한을 포함한 공급업체 약관을 따릅니다. 크랙 버전, 도난 키, 무단 키 공유, 라이선스 제한 우회는 허용되지 않습니다. 이 안내는 MIT 라이선스나 제3자 라이선스를 변경하지 않습니다.
**You must legally obtain and be authorized to use commercial tools**, including
IDA Pro / Hex-Rays decompilers, Hopper, and JEB, under their applicable licenses.
REA grants no tool license, key, or activation entitlement. Trials and demos
remain subject to vendor terms, including user, feature, automation, and output
sharing restrictions. Cracked copies, stolen keys, unauthorized key sharing,
and bypassing license restrictions are not authorized. This guidance does not
amend MIT or third-party licenses.
+23 -39
View File
@@ -1,12 +1,11 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { execFileSync } from "node:child_process";
import {
access,
copyFile,
mkdir,
mkdtemp,
readFile,
readdir,
rmdir,
rm,
stat,
symlink,
@@ -55,7 +54,7 @@ try {
join(workspace, "dist", "windows", "WindowsNativeLoader.js"),
).href;
const source = `import assert from 'node:assert/strict';
import {existsSync,mkdirSync,readFileSync,readdirSync,renameSync,statSync,writeFileSync} from 'node:fs';
import {existsSync,mkdirSync,readdirSync,renameSync,statSync} from 'node:fs';
import {join} from 'node:path';
import {systemWindowsNativeAuthority} from ${JSON.stringify(loader)};
const temp=process.env.TEMP;
@@ -65,61 +64,46 @@ 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.
writeFileSync(join(path,'owned-child.txt'),'owned child');
const renamed=path+'-renamed';renameSync(path,renamed);
const moved=statSync(renamed,{bigint:true});
assert.equal(moved.dev,identity.dev);assert.equal(moved.ino,identity.ino);
// Ownership follows the retained NTFS handle even when the directory moves.
// A replacement at its old pathname must remain caller-owned.
const moved=path+'-renamed';renameSync(path,moved);
assert.equal(statSync(moved,{bigint:true}).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'){
const second=systemWindowsNativeAuthority();assert.equal(second.available,true);
assert.equal(existsSync(renamed),false);
assert.equal(existsSync(moved),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);
}
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:process.argv[2],firstFailed:true,retainedOwner:true,inode:identity.ino.toString(),replacementName:name,replacementInode:replacement.ino.toString()}));
`;
const fixture = join(workspace, "probe.mjs");
await writeFile(fixture, source);
for (const mode of ["retry", "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 },
});
if (child.error !== undefined) throw child.error;
assert.equal(child.status, 0, child.stderr);
assert.equal(
child.stderr,
"",
"Native environment teardown reported a cleanup failure",
const observed = JSON.parse(
execFileSync(process.execPath, [fixture, mode], {
encoding: "utf8",
timeout: 20_000,
windowsHide: true,
env: { ...process.env, TEMP: temp, TMP: temp },
}),
);
const observed = JSON.parse(child.stdout);
assert.equal(observed.mode, mode);
assert.equal(observed.firstFailed, true);
assert.equal(observed.retainedOwner, true);
await assert.rejects(access(observed.renamed), { code: "ENOENT" });
assert.deepEqual(await readdir(temp), [observed.name]);
const replacement = join(temp, observed.name);
const identity = await stat(replacement, { bigint: true });
assert.equal(identity.dev.toString(), observed.device);
assert.equal(identity.ino.toString(), observed.replacementInode);
assert.notEqual(identity.ino.toString(), observed.inode);
assert.deepEqual(await readdir(replacement), ["unrelated.txt"]);
assert.deepEqual(await readdir(temp), [observed.replacementName]);
const replacement = join(temp, observed.replacementName);
assert.equal(
await readFile(join(replacement, "unrelated.txt"), "utf8"),
"unrelated replacement",
(await stat(replacement, { bigint: true })).ino.toString(),
observed.replacementInode,
);
await rmdir(replacement);
assert.deepEqual(await readdir(temp), []);
}
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, controlledDispositionFailure: true, retainedOwner: true, renamedOwnerCleanup: true, replacementPreserved: true, retryRecovers: true, successfulAdmissionCached: true, environmentTeardownCleansProbe: true, issue1701HostCauseReproduced: false })}\n`,
);
} finally {
await rm(workspace, { recursive: true, force: true });
@@ -167,7 +167,7 @@ export const parseZcodeFallbackConfiguration = (
? {}
: requireObject(document.mcpServers, "ZCode fallback mcpServers"),
serversPath: ["mcpServers"],
dialect: "zcode_fallback",
dialect: "json",
legacyServers: {},
};
};
@@ -183,7 +183,6 @@ export const parseClientConfiguration = (
if (format === undefined || format === "unsupported")
throw new TypeError("client does not have a supported MCP config format");
// DeepSeek Harness stores a Cordis patch list, not a server table.
if (format === "zcode_fallback") return parseZcodeFallbackConfiguration(text);
if (format === "dsh") return parseDshCordisDocument(text);
const document = parseDocument(text, format);
const serversKey = clientConfigurationServersKey(format);
+8 -22
View File
@@ -12,7 +12,7 @@ import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.j
import type { AnalysisCleanupObservation } from "../domain/analysisErrorBase.js";
import { readReferenceSource } from "../reference/ReferenceSourceReader.js";
import type {
ReferenceSourceExclusionKind,
ReferenceSourceEntryKind,
ReferenceSourceRead,
ReferenceSourceReaderError,
} from "../reference/ReferenceSourceReaderTypes.js";
@@ -111,8 +111,7 @@ const deriveInventoryState = (
entry.limitations.length > 0 ||
(entry.kind === "file" && entry.content_state !== "hashed") ||
(entry.kind === "directory" && entry.tree_state !== "enumerated") ||
(entry.kind === "symlink" && entry.target_state !== "internal") ||
entry.kind === "unknown",
(entry.kind === "symlink" && entry.target_state !== "internal"),
);
if (partialEntry) return "partial";
const partialRelationship = input.relationships.some(
@@ -143,24 +142,11 @@ const createShouldExclude =
exclusions: HistoricalSourceGraphInput["exclusions"],
secrets: PreparedReferenceSourceImport["secrets"],
ignored: PreparedReferenceSourceImport["ignored"],
): ((path: string, kind: ReferenceSourceExclusionKind) => boolean) =>
): ((path: string, kind: ReferenceSourceEntryKind) => boolean) =>
(path, kind) => {
// An entry of unknown kind may be a directory, so directory-only rules
// must still exclude it; this fails closed for caller-marked secrets.
const patternPaths =
kind === "directory"
? [`${path}/`]
: kind === "unknown"
? [path, `${path}/`]
: [path];
const firstMatch = (matcher: typeof secrets) => {
const results = patternPaths.map((patternPath) =>
matcher.test(patternPath),
);
return results.find(({ ignored }) => ignored) ?? results[0];
};
const secretMatch = firstMatch(secrets);
if (secretMatch?.ignored === true) {
const patternPath = kind === "directory" ? `${path}/` : path;
const secretMatch = secrets.test(patternPath);
if (secretMatch.ignored) {
if (!secretMatch.rule)
throw new Error(`Ignored secret path has no matching rule: ${path}`);
exclusions.push({
@@ -170,8 +156,8 @@ const createShouldExclude =
});
return true;
}
const match = firstMatch(ignored);
if (match?.ignored !== true) return false;
const match = ignored.test(patternPath);
if (!match.ignored) return false;
if (!match.rule)
throw new Error(`Ignored path has no matching rule: ${path}`);
const reason = match.rule.mark;
@@ -45,8 +45,6 @@ const entryRecoveryGuidance = (
return "This directory could not be read. Check its permissions, then try again.";
if (entry.kind === "symlink")
return "This symbolic link could not be read safely. Check the link and its permissions, then try again.";
if (entry.kind === "unknown")
return "This entry's type could not be determined. Check the parent directory and try again.";
return "This file could not be read. Check its permissions, then try again.";
};
@@ -75,13 +73,6 @@ const failedEntry = (
classifications,
limitations: [limitation],
};
if (entry.kind === "unknown")
return {
path: entry.path,
kind: "unknown",
classifications: ["unknown"],
limitations: [limitation],
};
return {
path: entry.path,
kind: "file",
-1
View File
@@ -47,7 +47,6 @@ export interface SetupClient {
| "pi"
| "kimi"
| "hermes"
| "zcode_fallback"
| "zcode"
| "dsh"
| "unsupported";
+3 -12
View File
@@ -231,21 +231,12 @@ export const systemUninstallHost = (
const clients = supportedClients(home, platform, environment);
redirection = appDataRedirection(clients);
return Promise.resolve(
clients.flatMap((client) => [
...uninstallConfigPaths(client).map((configPath) => ({
clients.flatMap((client) =>
uninstallConfigPaths(client).map((configPath) => ({
...client,
configPath,
})),
...(client.zcodeFallbackPath === undefined
? []
: [
{
...client,
configPath: client.zcodeFallbackPath,
format: "zcode_fallback" as const,
},
]),
]),
),
);
},
inspectClient: async (client) => {
File diff suppressed because one or more lines are too long
+11 -3
View File
@@ -21,13 +21,13 @@ export const managedTargetInputSchema = z.strictObject({
.optional(),
});
/** Delivery selection belongs only to artifact inspection, not member/native queries. */
/** Delivery selection belongs to artifact and member inspection, not native queries. */
export const managedArtifactInputSchema = managedTargetInputSchema.extend({
detail: analysisDetailSchema.unwrap().optional(),
});
/** Member inspection may select declaring types before members are decoded. */
export const managedMembersInputSchema = managedTargetInputSchema.extend({
export const managedMembersInputSchema = managedArtifactInputSchema.extend({
type_names: z
.array(
z
@@ -87,7 +87,7 @@ export const MANAGED_TOOL_CONTRACTS = [
name: "inspect_managed_members",
...toolContractMetadata("inspect_managed_members"),
description:
"Inspect PE/CLI metadata members, signatures, raw CIL hashes, decoded-instruction-tuple hashes, separately reported exception regions, call edges, field-access anchors, and the #US string literals that ldstr loads, without loading or executing target code. Select type_names to decode only the members of those exact declaring types and the literals their bodies load, which keeps large assemblies such as a base class library within the representation budget. Metadata tokens are reported as build-local coordinates bound to the artifact SHA-256 and MVID; the tuple hash does not resolve them or fully commit control flow.",
'Inspect PE/CLI metadata members, signatures, raw CIL hashes, decoded-instruction-tuple hashes, separately reported exception regions, call edges, field-access anchors, and the #US string literals that ldstr loads, without loading or executing target code. Select type_names to decode only the members of those exact declaring types and the literals their bodies load, which keeps large assemblies such as a base class library within the representation budget. Metadata tokens are reported as build-local coordinates bound to the artifact SHA-256 and MVID; the tuple hash does not resolve them or fully commit control flow. Omitted or complete detail returns every collection, including the assembly-wide member_refs and exported_types, which type_names does not narrow. Select detail: "summary" for identity, metadata status, selection with unmatched names, coverage/issues, limitations and per-collection counts; it retains complete Evidence and returns a linked inspect_analysis_view summary. Page original rows with view kind managed-members, normalized_result.parent_evidence_id and collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings.',
kind: "managed-provider",
inputSchema: managedMembersInputSchema,
outputSchema: memberOutputSchema,
@@ -103,6 +103,14 @@ export const MANAGED_TOOL_CONTRACTS = [
type_names: ["System.Threading.Monitor"],
},
},
{
title: "Summarize selected managed types and retain their members",
input: {
path: "/tmp/mscorlib.dll",
type_names: ["System.Threading.Monitor"],
detail: "summary",
},
},
],
},
{
+7 -1
View File
@@ -4,6 +4,7 @@ import {
artifactInventorySummaryViewResultSchema,
machoSummaryViewResultSchema,
managedArtifactSummaryViewResultSchema,
managedMemberSummaryViewResultSchema,
} from "../domain/analysisView/analysisView.js";
import { evidenceBundleSummarySchema } from "../domain/evidenceRecordSummary.js";
import { BINARY_ARCHITECTURES } from "../domain/binaryTargetTypes.js";
@@ -418,7 +419,12 @@ export const managedOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
managedArtifactSummaryViewResultSchema,
]),
),
inspect_managed_members: evidenceResultOf(managedMemberInspectionSchema),
inspect_managed_members: evidenceResultOf(
z.union([
managedMemberInspectionSchema,
managedMemberSummaryViewResultSchema,
]),
),
inspect_managed_native_boundaries: evidenceResultOf(
managedNativeBoundaryInspectionSchema,
),
+31 -1
View File
@@ -49,6 +49,13 @@ import {
managedSummarySchema,
managedViewContextSchema,
} from "./managedArtifactView.js";
import {
projectManagedMemberView,
managedMembersViewRequestSchema,
managedMembersViewPageSchema,
managedMemberSummarySchema,
managedMembersViewContextSchema,
} from "./managedMemberView.js";
import {
projectWasmArtifactView,
wasmViewRequestSchema,
@@ -208,6 +215,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [
machoViewRequestSchema,
evmViewRequestSchema,
managedViewRequestSchema,
managedMembersViewRequestSchema,
androidViewRequestSchema,
apiSurfaceViewSchema,
captureViewRequestSchema,
@@ -328,6 +336,7 @@ const viewResultBase = {
"inspect_macho",
"inspect_evm_interface",
"inspect_managed_artifact",
"inspect_managed_members",
"inspect_web_network_capture",
"inspect_android_package",
"inspect_pcap_capture",
@@ -396,6 +405,13 @@ export const managedArtifactSummaryViewResultSchema = z.strictObject({
summary: managedSummarySchema,
});
/** Exact member selection, coverage and counts for the selected delivery. */
export const managedMemberSummaryViewResultSchema = z.strictObject({
...summaryViewResultBase,
parent_operation: z.literal("inspect_managed_members"),
summary: managedMemberSummarySchema,
});
/** Exact historical capture summary, with no body or member-inventory payload. */
export const webNetworkCaptureSummaryViewResultSchema = z.strictObject({
...summaryViewResultBase,
@@ -446,6 +462,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
machoSummarySchema,
evmSummarySchema,
managedSummarySchema,
managedMemberSummarySchema,
captureSummarySchema,
androidPackageSummarySchema,
pcapSummarySchema,
@@ -529,6 +546,14 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
...managedViewContextSchema.shape,
parent_operation: z.literal("inspect_managed_artifact"),
}),
z.strictObject({
kind: z.literal("managed-members"),
view: managedMembersViewRequestSchema,
page: managedMembersViewPageSchema,
...viewResultBase,
...managedMembersViewContextSchema.shape,
parent_operation: z.literal("inspect_managed_members"),
}),
z.strictObject({
kind: z.literal("android"),
view: androidViewRequestSchema,
@@ -622,6 +647,10 @@ export type UnsignedAnalysisView =
Extract<AnalysisViewResult, { readonly kind: "managed" }>,
"view_digest"
>
| Omit<
Extract<AnalysisViewResult, { readonly kind: "managed-members" }>,
"view_digest"
>
| Omit<
Extract<AnalysisViewResult, { readonly kind: "android" }>,
"view_digest"
@@ -674,7 +703,7 @@ const unsupportedParent = (
new AnalysisUnsupportedTargetError(
INSPECT_ANALYSIS_VIEW_OPERATION,
parentArtifactPath(parent),
`Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed binary layout, JavaScript application, native function, artifact inventory/inspection, WASM, Mach-O, EVM, managed artifact, Android package, Dart AOT, or web network capture Evidence.`,
`Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed binary layout, JavaScript application, native function, artifact inventory/inspection, WASM, Mach-O, EVM, managed artifact or member, Android package, Dart AOT, or web network capture Evidence.`,
);
const parentArtifactPath = (parent: AnalysisViewParent): string => {
@@ -704,6 +733,7 @@ const retainedViewProjectors = new Map<string, typeof projectWasmArtifactView>([
["inspect_macho", projectMachoView],
["inspect_evm_interface", projectEvmInterfaceView],
["inspect_managed_artifact", projectManagedArtifactView],
["inspect_managed_members", projectManagedMemberView],
["inspect_android_package", projectAndroidPackageView],
["inspect_dart_aot", projectDartAotView],
["inspect_artifact", projectArtifactInventoryView],
+19 -11
View File
@@ -51,6 +51,24 @@ export const managedViewContextSchema = z.strictObject({
source_coverage: managedArtifactInspectionSchema.shape.coverage,
});
/** Token scope and identity limits shared by every managed metadata view. */
export const managedViewIdentityUnknowns = (
parent: AnalysisViewParent,
mvid: string | null,
): string[] => [
"Metadata tokens are build-local coordinates bound to the recorded artifact SHA-256 and module MVID; this view does not load or execute the assembly.",
...(parent.subject == null
? [
"The parent has no recorded subject; its normalized artifact identity cannot be cross-checked against a subject digest.",
]
: []),
...(mvid === null
? [
"The module MVID is unknown; tokens cannot establish a cross-observation module match.",
]
: []),
];
/** Project authenticated metadata without reading a path or invoking a provider. */
export const projectManagedArtifactView = (
parent: AnalysisViewParent,
@@ -96,17 +114,7 @@ export const projectManagedArtifactView = (
limitations: uniqueSorted([...parent.limitations, ...report.limitations]),
unknowns: [
"Collection counts and pages cover producer-returned rows; metadata completeness remains the original metadata status and coverage, not an absence claim about unreturned rows.",
"Metadata tokens are build-local coordinates bound to the recorded artifact SHA-256 and module MVID; this view does not load or execute the assembly.",
...(parent.subject == null
? [
"The parent has no recorded subject; its normalized artifact identity cannot be cross-checked against a subject digest.",
]
: []),
...(identity_scope.requires_mvid === null
? [
"The module MVID is unknown; tokens cannot establish a cross-observation module match.",
]
: []),
...managedViewIdentityUnknowns(parent, identity_scope.requires_mvid),
],
};
if (view.kind === "summary") {
@@ -0,0 +1,270 @@
import { z } from "zod";
import type { AnalysisError } from "../analysisErrorBase.js";
import { uniqueSorted } from "../canonicalOrdering.js";
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
import {
managedMemberInspectionSchema,
type ManagedMemberInspection,
} from "../managed/managedArtifact.js";
import { err, ok, type Result } from "../result.js";
import {
analysisViewInputError,
type AnalysisViewParent,
type AnalysisViewRequest,
type UnsignedAnalysisView,
} from "./analysisView.js";
import {
completeWithinViewCoverage,
pageViewCoverage,
} from "./analysisViewCoverage.js";
import { managedViewIdentityUnknowns } from "./managedArtifactView.js";
const member = managedMemberInspectionSchema.shape;
/** Original member rows; offsets count entries in the returned parent collection. */
export const managedMembersViewRequestSchema = z.strictObject({
kind: z.literal("managed-members"),
collection: z.enum([
"types",
"exported_types",
"fields",
"methods",
"member_refs",
"call_edges",
"field_accesses",
"user_strings",
]),
offset: z.number().int().nonnegative().default(0),
limit: z.number().int().positive().default(64),
});
const rowsOf = <C extends string, S extends z.ZodArray>(
collection: C,
rows: S,
) => z.strictObject({ collection: z.literal(collection), rows });
/**
* Rows of one collection, keyed by that collection: several row schemas accept
* each other's rows after stripping keys, so no union may pick one by order.
*/
export const managedMembersViewPageSchema = z.discriminatedUnion("collection", [
rowsOf("types", member.types),
rowsOf("exported_types", member.exported_types.unwrap().unwrap()),
rowsOf("fields", member.fields),
rowsOf("methods", member.methods),
rowsOf("member_refs", member.member_refs),
rowsOf("call_edges", member.call_edges),
rowsOf("field_accesses", member.field_accesses),
rowsOf("user_strings", member.user_strings.unwrap().unwrap().shape.literals),
]);
type ManagedMembersViewPage = z.infer<typeof managedMembersViewPageSchema>;
const nullableCount = z.number().int().nonnegative().nullable();
/** Keep identity, selection and coverage; member collections remain retained. */
export const managedMemberSummarySchema = z.strictObject({
artifact: member.artifact,
module: member.module,
metadata: member.metadata,
identity_scope: member.identity_scope,
selection: member.selection.unwrap(),
coverage: member.coverage,
user_strings: z
.strictObject({
state: member.user_strings.unwrap().unwrap().shape.state,
issues: member.user_strings.unwrap().unwrap().shape.issues,
})
.nullable(),
limitations: member.limitations,
reported_counts: z.strictObject({
types: z.number().int().nonnegative(),
exported_types: nullableCount,
fields: z.number().int().nonnegative(),
methods: z.number().int().nonnegative(),
member_refs: z.number().int().nonnegative(),
call_edges: z.number().int().nonnegative(),
field_accesses: z.number().int().nonnegative(),
user_strings: nullableCount,
}),
});
export const managedMembersViewContextSchema = z.strictObject({
identity_scope: member.identity_scope,
selection: member.selection.unwrap(),
source_metadata_status: member.metadata.shape.status,
source_coverage: member.coverage,
});
type ManagedMembersViewRequest = z.infer<
typeof managedMembersViewRequestSchema
>;
type MemberCollection = ManagedMembersViewRequest["collection"];
const slice = <C extends MemberCollection, T>(
collection: C,
rows: readonly T[] | null | undefined,
{ offset, limit }: { readonly offset: number; readonly limit: number },
) =>
rows == null
? null
: {
page: { collection, rows: rows.slice(offset, offset + limit) },
total: rows.length,
};
/** One page of a collection, or null when the parent never examined it. */
const collectionPage = (
report: ManagedMemberInspection,
view: ManagedMembersViewRequest,
): { readonly page: ManagedMembersViewPage; readonly total: number } | null => {
const { collection } = view;
switch (collection) {
case "types":
return slice(collection, report.types, view);
case "exported_types":
return slice(collection, report.exported_types, view);
case "fields":
return slice(collection, report.fields, view);
case "methods":
return slice(collection, report.methods, view);
case "member_refs":
return slice(collection, report.member_refs, view);
case "call_edges":
return slice(collection, report.call_edges, view);
case "field_accesses":
return slice(collection, report.field_accesses, view);
case "user_strings":
return slice(collection, report.user_strings?.literals, view);
}
};
/** Everything but the member collections, which are replaced by their counts. */
const summarizeMembers = (
report: ManagedMemberInspection,
): z.infer<typeof managedMemberSummarySchema> => {
const {
types,
exported_types,
fields,
methods,
member_refs,
call_edges,
field_accesses,
user_strings,
...identity
} = report;
return {
...identity,
user_strings:
user_strings === null
? null
: { state: user_strings.state, issues: user_strings.issues },
reported_counts: {
types: types.length,
exported_types: exported_types?.length ?? null,
fields: fields.length,
methods: methods.length,
member_refs: member_refs.length,
call_edges: call_edges.length,
field_accesses: field_accesses.length,
user_strings: user_strings?.literals.length ?? null,
},
};
};
/** Project authenticated member Evidence without reading a path or invoking a provider. */
export const projectManagedMemberView = (
parent: AnalysisViewParent,
view: AnalysisViewRequest,
): Result<UnsignedAnalysisView, AnalysisError> => {
if (view.kind !== "summary" && view.kind !== "managed-members")
return err(
analysisViewInputError([
{
path: ["view"],
reason: "invalid_value",
message:
"Managed member inspection supports summary and managed-members views.",
},
]),
);
const parsed = managedMemberInspectionSchema.safeParse(
parent.normalizedResult,
);
if (!parsed.success)
return err(
analysisInputErrorFromIssues(
"inspect_analysis_view",
parsed.error.issues.map((issue) => ({
...issue,
path: ["source", "normalized_result", ...issue.path],
})),
{ source: { normalized_result: parent.normalizedResult } },
{ cause: parsed.error },
),
);
const report = parsed.data;
const shared = {
parent_evidence_id: parent.evidenceId,
parent_operation: "inspect_managed_members" as const,
parent_digest: parent.evidenceId.slice(3),
artifact: { path: report.artifact.path, sha256: report.artifact.sha256 },
limitations: uniqueSorted([...parent.limitations, ...report.limitations]),
unknowns: [
"Collection counts and pages cover producer-returned rows; member completeness remains the original coverage, not an absence claim about unreturned rows.",
...(report.selection === null
? []
: [
"Exported types and member references cover the whole assembly; the other collections cover only the selected declaring types.",
]),
...managedViewIdentityUnknowns(
parent,
report.identity_scope.requires_mvid,
),
],
};
if (view.kind === "summary") {
const summary = summarizeMembers(report);
const total = Object.values(summary.reported_counts).reduce<number>(
(sum, count) => sum + (count ?? 0),
0,
);
return ok({
kind: "summary",
view,
...shared,
summary,
coverage: completeWithinViewCoverage(total, total),
});
}
const selected = collectionPage(report, view);
if (selected === null)
return err(
analysisViewInputError([
{
path: ["view", "collection"],
reason: "invalid_value",
message: `The parent Evidence did not examine ${view.collection}; repeat inspect_managed_members with the current runtime to record it.`,
},
]),
);
const { page, total } = selected;
return ok({
kind: "managed-members",
view,
page,
...shared,
identity_scope: report.identity_scope,
selection: report.selection,
source_metadata_status: report.metadata.status,
source_coverage:
view.collection === "user_strings" && report.user_strings !== null
? {
state: report.user_strings.state,
issues: report.user_strings.issues,
}
: report.coverage,
coverage: pageViewCoverage(view.offset, page.rows.length, total),
});
};
-24
View File
@@ -259,30 +259,6 @@ describe("historical source graph", () => {
});
describe("partial historical source graphs", () => {
it("keeps unknown-entry diagnostics outside the root commitment", () => {
const base = graphInput();
base.inventory_state = "partial";
base.entries[2] = {
path: "src/package.json",
kind: "unknown",
classifications: ["unknown"],
limitations: ["Permission denied at /private/first"],
};
const graph = createHistoricalSourceGraph(base);
const changedMessage = structuredClone(base);
changedMessage.entries[2] = {
path: "src/package.json",
kind: "unknown",
classifications: ["unknown"],
limitations: ["Permission denied at /private/second"],
};
const revised = createHistoricalSourceGraph(changedMessage);
expect(revised.root_sha256).toBe(graph.root_sha256);
expect(computeHistoricalSourceGraphSha256(revised)).not.toBe(
computeHistoricalSourceGraphSha256(graph),
);
});
it("keeps an unreadable symlink target unknown and out of root identity", () => {
const base = graphInput();
base.inventory_state = "partial";
+1 -11
View File
@@ -111,10 +111,6 @@ const sourceEntrySchema = z.union([
sourceFileSchema,
sourceDirectorySchema,
sourceSymlinkSchema,
z.strictObject({
...entryBaseShape,
kind: z.literal("unknown"),
}),
]);
const sourceRelationshipSchema = z.strictObject({
@@ -365,8 +361,7 @@ const checkCompleteInventory = (
entry.limitations.length > 0 ||
(entry.kind === "file" && entry.content_state !== "hashed") ||
(entry.kind === "directory" && entry.tree_state !== "enumerated") ||
(entry.kind === "symlink" && entry.target_state !== "internal") ||
entry.kind === "unknown",
(entry.kind === "symlink" && entry.target_state !== "internal"),
);
const partialRelationship = graph.relationships.some(
({ parse_state, resolution }) =>
@@ -483,11 +478,6 @@ const rootCommitment = (
target: entry.target,
target_state: entry.target_state,
};
case "unknown":
return {
kind: entry.kind,
path: entry.path,
};
}
}),
exclusions,
-20
View File
@@ -81,26 +81,6 @@ describe("committed historical-source summaries", () => {
expect(graph).toEqual(original);
});
it("counts entries of unknown kind separately from symlinks", () => {
const graph = createHistoricalSourceGraph(
input([
{
path: "deep",
kind: "unknown",
classifications: ["unknown"],
limitations: ["lstat: ENAMETOOLONG"],
},
]),
);
expect(createHistoricalSourceSummary(graph, 1).counts).toMatchObject({
entries: 1,
files: 0,
directories: 0,
symlinks: 0,
unknown: 1,
});
});
it("sums known bytes losslessly beyond the JSON safe integer range", () => {
const graph = createHistoricalSourceGraph(
input(
+1 -3
View File
@@ -29,7 +29,6 @@ export const createHistoricalSourceSummary = (
files: 0,
directories: 0,
symlinks: 0,
unknown: 0,
relationships: graph.relationships.length,
parse_failures: graph.parse_failures.length,
exclusions: graph.exclusions.length,
@@ -60,8 +59,7 @@ export const createHistoricalSourceSummary = (
} else if (entry.kind === "directory") {
counts.directories += 1;
treeStates[entry.tree_state] += 1;
} else if (entry.kind === "symlink") counts.symlinks += 1;
else counts.unknown += 1;
} else counts.symlinks += 1;
// Canonical entries already use Unicode code-point order; filtering preserves it.
if (entry.path.split("/").length <= outlineDepth)
outline.push(outlineEntry(entry));
+1 -1
View File
@@ -5,5 +5,5 @@ export const PACKAGE_METADATA = {
serverSdkVersion: "2.3.1",
clientSdkVersion: "2.3.1",
coreSdkVersion: "2.3.1",
skillVersion: "42",
skillVersion: "41",
} as const;
+3 -10
View File
@@ -25,7 +25,7 @@ import {
import type {
PendingDirectory,
ReferenceSourceEntry,
ReferenceSourceExclusionKind,
ReferenceSourceEntryKind,
ReferenceSourceResult,
TraversalState,
} from "./ReferenceSourceReaderTypes.js";
@@ -201,13 +201,6 @@ const processEntry = async (
const metadata = await readMetadata(absolute);
if (isAborted(state.signal)) return { ok: false, error: cancelled() };
if (!metadata.ok) {
const excluded = applyExclusion(state.shouldExclude, path, "unknown");
if (!excluded.ok)
return {
ok: false,
error: failure("io", "Reference source exclusion check failed"),
};
if (excluded.value) return { ok: true, value: undefined };
state.entries.push(entryFailure(path, "unknown", "io", metadata.message));
return { ok: true, value: undefined };
}
@@ -378,10 +371,10 @@ const readSymlinkTarget = async (
const applyExclusion = (
shouldExclude:
| ((path: string, kind: ReferenceSourceExclusionKind) => boolean)
| ((path: string, kind: ReferenceSourceEntryKind) => boolean)
| undefined,
path: string,
kind: ReferenceSourceExclusionKind,
kind: ReferenceSourceEntryKind,
): { readonly ok: true; readonly value: boolean } | { readonly ok: false } => {
try {
return { ok: true, value: shouldExclude?.(path, kind) === true };
@@ -29,9 +29,6 @@ const FILESYSTEM_ERROR_CODES = new Set([
"EISDIR",
"ELOOP",
"EMFILE",
// A traversed child can exceed the platform's absolute path limit even
// though the selected root and every component are valid.
"ENAMETOOLONG",
"ENFILE",
"ENOENT",
"ENOTDIR",
@@ -47,8 +44,6 @@ export const filesystemFailureDetail = (
): string | undefined => {
if (!(cause instanceof Error)) return undefined;
const code: unknown = Reflect.get(cause, "code");
if (code === "ENAMETOOLONG")
return `${operation}: ENAMETOOLONG (entry path exceeds the operating system path limit)`;
return typeof code === "string" && FILESYSTEM_ERROR_CODES.has(code)
? `${operation}: ${cause.message}`
: undefined;
+2 -8
View File
@@ -10,17 +10,11 @@ export type ReferenceSourceEntryKind =
| "symlink"
| "other";
/**
* Kind offered to exclusion predicates. `unknown` marks an entry whose
* metadata could not be read, so its concrete kind cannot be claimed.
*/
export type ReferenceSourceExclusionKind = ReferenceSourceEntryKind | "unknown";
export interface ReferenceSourceReaderOptions {
readonly signal?: AbortSignal;
readonly shouldExclude?: (
path: string,
kind: ReferenceSourceExclusionKind,
kind: ReferenceSourceEntryKind,
) => boolean;
}
@@ -90,7 +84,7 @@ export type TraversalState = {
readonly signal?: AbortSignal;
readonly shouldExclude?: (
path: string,
kind: ReferenceSourceExclusionKind,
kind: ReferenceSourceEntryKind,
) => boolean;
readonly entries: ReferenceSourceEntry[];
readonly pending: PendingDirectory[];
+13 -10
View File
@@ -16,6 +16,15 @@ import { registerEvidenceTools } from "./registerEvidenceTools.js";
import { runManagedProviderExecution } from "../composition/directAnalysis.js";
import { isManagedToolName } from "../contracts/managed/managedToolContracts.js";
/** Operations whose caller may select a retained summary instead of complete delivery. */
const summarizesRetainedEvidence = (
operation: string,
parameters: Readonly<Record<string, unknown>>,
): boolean =>
(operation === "inspect_managed_artifact" ||
operation === "inspect_managed_members") &&
parameters.detail === "summary";
/** Register execution-free managed PE/CLI inspection. */
export const registerManagedTools = (
server: EvidenceMcpServer,
@@ -34,11 +43,8 @@ export const registerManagedTools = (
execute: async (operation, parameters, executionOptions) => {
if (!isManagedToolName(operation))
return analysis.execute(operation, parameters, executionOptions);
const { detail: _detail, ...artifactParameters } = parameters;
if (
operation === "inspect_managed_artifact" &&
parameters.detail === "summary"
) {
const { detail: _detail, ...providerParameters } = parameters;
if (summarizesRetainedEvidence(operation, parameters)) {
const ready = requireAnalysisViewRetention(operation, {
recordEvidence: options.recordEvidence,
evidenceById: options.evidenceById,
@@ -69,9 +75,7 @@ export const registerManagedTools = (
path,
operation,
executionOptions?.signal,
operation === "inspect_managed_artifact"
? artifactParameters
: parameters,
providerParameters,
);
if (execution.ok && typeof requestedPath === "string")
selectedManagedPath = requestedPath;
@@ -82,8 +86,7 @@ export const registerManagedTools = (
...options,
analysisForAdmission,
projectEvidence: (operation, parameters, evidence) =>
operation === "inspect_managed_artifact" &&
parameters.detail === "summary"
summarizesRetainedEvidence(operation, parameters)
? summarizeRetainedAnalysis(evidence, {
recordEvidence: options.recordEvidence,
evidenceById: options.evidenceById,
+22 -40
View File
@@ -279,44 +279,26 @@ it("preserves bounded instruction coverage separately from using-node coverage",
});
});
it.each([
"not-a-dex-descriptor",
"(Lbad(name;)V",
"(Lbad)name;)V",
"()Lbad(name;",
"()Lbad)name;",
])(
"rejects malformed DEX prototype %s at the adapter boundary",
async (descriptor) => {
const invalidSummary = {
...summary,
methods: summary.methods.map((method) => ({
...method,
dex_descriptor: descriptor,
})),
};
await expect(
analyzeJadxRequest(
{
json: () => Promise.resolve(invalidSummary),
text: () => Promise.reject(new Error("unexpected text call")),
},
{
operation: "inspect_android_class",
input: { path: "/fixture.apk", class_name: "fixture.Target" },
},
{} as never,
engine,
),
).rejects.toMatchObject({
_tag: "AnalysisOutputError",
cause: {
issues: expect.arrayContaining([
expect.objectContaining({
path: ["methods", 0, "dex_descriptor"],
}),
]),
it("rejects malformed method descriptors at the Android adapter boundary", async () => {
const invalidSummary = {
...summary,
methods: summary.methods.map((method) => ({
...method,
dex_descriptor: "not-a-dex-descriptor",
})),
};
await expect(
analyzeJadxRequest(
{
json: () => Promise.resolve(invalidSummary),
text: () => Promise.reject(new Error("unexpected text call")),
},
});
},
);
{
operation: "inspect_android_class",
input: { path: "/fixture.apk", class_name: "fixture.Target" },
},
{} as never,
engine,
),
).rejects.toThrow();
});
@@ -133,88 +133,3 @@ describe("reference import native entry failure details", () => {
).toMatchObject({ kind: "file", content_state: "hashed", limitations: [] });
});
});
describe("reference import paths beyond the platform limit", () => {
it.skipIf(process.platform === "win32")(
"reports an entry beyond the absolute path limit as an I/O failure",
async () => {
const root = await createTestTempDirectory("rea-import-deep-path-");
await writeFile(join(root, "kept.txt"), "k");
// Relative creation lets the tree exceed PATH_MAX (1024 on macOS, 4096
// on Linux) while each 200-byte component stays below NAME_MAX.
const deepTree = (action: "create" | "remove") =>
promisify(execFile)(process.execPath, [
"-e",
`const fs = require("node:fs");
const names = Array.from({ length: 24 }, (_, i) => "d" + String(i).padStart(2, "0") + "x".repeat(197));
process.chdir(${JSON.stringify(root)});
if (${JSON.stringify(action)} === "create") {
for (const name of names) { fs.mkdirSync(name); process.chdir(name); }
fs.writeFileSync("deep.txt", "d");
} else {
for (const name of names) process.chdir(name);
fs.rmSync("deep.txt");
for (const name of [...names].reverse()) { process.chdir(".."); fs.rmdirSync(name); }
}`,
]);
await deepTree("create");
try {
const read = await readReferenceSource(root);
if (!read.ok) throw new Error(read.error.message);
expect(read.value.entries).toContainEqual(
expect.objectContaining({ path: "kept.txt", status: "read" }),
);
const failed = read.value.entries.find(
(entry) => entry.status === "failed",
);
expect(failed).toMatchObject({
status: "failed",
code: "io",
message: expect.stringContaining("ENAMETOOLONG"),
});
const filteredRead = await readReferenceSource(root, {
shouldExclude: (path, kind) =>
path === failed?.path && kind === "directory",
});
if (!filteredRead.ok) throw new Error(filteredRead.error.message);
expect(
filteredRead.value.entries.find(
(entry) => entry.path === failed?.path,
),
).toMatchObject({ status: "failed", kind: "unknown", code: "io" });
const imported = await importTree(root);
if (!imported.ok) throw new Error(imported.error.message);
expect(imported.value.entries).toContainEqual(
expect.objectContaining({
path: "kept.txt",
content_state: "hashed",
}),
);
const deep = imported.value.entries.find(
(entry) => entry.path === failed?.path,
);
expect(deep?.limitations.join(" ")).toContain(
failed?.status === "failed" ? failed.message : "missing failure",
);
// A directory-only secret rule still applies when the kind is unknown.
const secretPattern = `${failed?.path ?? "missing"}/`;
const secret = await importReferenceSource({
root,
caller: "native-entry-failure-detail-test",
policy: { secretPatterns: [secretPattern] },
});
if (!secret.ok) throw new Error(secret.error.message);
expect(
secret.value.entries.some(({ path }) => path === failed?.path),
).toBe(false);
expect(secret.value.exclusions).toContainEqual({
path: failed?.path,
reason: "configured-secret",
pattern: secretPattern,
});
} finally {
await deepTree("remove");
}
},
);
});
@@ -13,10 +13,7 @@ import {
} from "../../../src/application/SetupClientConfiguration.js";
import { skillDestinations } from "../../../src/application/SetupSkill.js";
import { supportedClients } from "../../../src/application/SupportedClients.js";
import {
runUninstall,
systemUninstallHost,
} from "../../../src/application/Uninstall.js";
import { systemUninstallHost } from "../../../src/application/Uninstall.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js";
@@ -260,91 +257,6 @@ describe("ZCode .agents fallback preservation", () => {
});
});
describe("ZCode fallback uninstall", () => {
const owned = { command: command[0], args: command.slice(1) };
const writeFallback = async (home: string, servers: object) => {
await mkdir(join(home, ".agents"), { recursive: true });
const text = `${JSON.stringify({ ui: { keep: true }, mcpServers: servers }, null, 2)}\n`;
await writeFile(fallbackPath(home), text);
return text;
};
it.each(["comment", "trailing-comma", "bom"])(
"fails closed on strict fallback syntax: %s",
async (mode) => {
const { home } = await zcodeClient();
const valid = await writeFallback(home, {
rea: owned,
other: { command: "other-server" },
});
const original =
mode === "comment"
? `// unsupported comment\n${valid}`
: mode === "bom"
? `\uFEFF${valid}`
: valid.replace(/\}\s*$/u, ",}");
await writeFile(fallbackPath(home), original);
const result = await runUninstall(false, systemUninstallHost(home));
expect(result.items).toContainEqual(
expect.objectContaining({ name: "zcode", status: "failed" }),
);
expect(await readFile(fallbackPath(home), "utf8")).toBe(original);
await expect(
readFile(`${fallbackPath(home)}.rea.backup`),
).rejects.toMatchObject({ code: "ENOENT" });
},
);
it("removes the REA-owned fallback entry with the native registration", async () => {
const { home, client } = await zcodeClient();
await writeFile(
client.configPath,
JSON.stringify({ mcp: { servers: { rea: owned } } }),
);
const original = await writeFallback(home, {
rea: owned,
other: { command: "other-server" },
});
const result = await runUninstall(false, systemUninstallHost(home));
expect(result.status).toBe("complete");
expect(result.items.find(({ name }) => name === "zcode")?.status).toBe(
"removed",
);
const native = JSON.parse(await readFile(client.configPath, "utf8"));
expect(native.mcp.servers).toEqual({});
const fallback = JSON.parse(await readFile(fallbackPath(home), "utf8"));
expect(fallback).toEqual({
ui: { keep: true },
mcpServers: { other: { command: "other-server" } },
});
expect(await readFile(`${fallbackPath(home)}.rea.backup`, "utf8")).toBe(
original,
);
});
it("removes a fallback-only REA entry and keeps a foreign rea entry", async () => {
for (const [servers, expected] of [
[{ rea: owned }, {}],
[
{ rea: { command: "someone-else" } },
{ rea: { command: "someone-else" } },
],
] as const) {
const { home } = await zcodeClient();
await writeFallback(home, servers);
expect(
(await runUninstall(false, systemUninstallHost(home))).status,
).toBe("complete");
expect(
JSON.parse(await readFile(fallbackPath(home), "utf8")).mcpServers,
).toEqual(expected);
}
});
});
describe("ZCode fallback admission", () => {
it.each([{ PORT: 123 }, { REA_MCP_INPUT_SCHEMA_PROFILE: "unknown" }])(
"refuses malformed fallback REA environment %j before native writes",
@@ -49,16 +49,13 @@ it("returns a first-call overview and pages the unchanged source after its file
const ajv = new Ajv2020({ strict: false, validateFormats: false });
expect(ajv.validateSchema(definition.inputSchema)).toBe(true);
expect(ajv.validateSchema(outputSchema)).toBe(true);
for (const name of [
"inspect_managed_members",
"inspect_managed_native_boundaries",
])
expect(
ajv.validate(catalog.find((x) => x.name === name)!.inputSchema, {
path,
detail: "summary",
}),
).toBe(false);
expect(
ajv.validate(
catalog.find((x) => x.name === "inspect_managed_native_boundaries")!
.inputSchema,
{ path, detail: "summary" },
),
).toBe(false);
const reply = await client.callTool({
name: "inspect_managed_artifact",
arguments: { path, detail: "summary" },
@@ -239,11 +236,15 @@ it("refuses selected delivery before file access when the server cannot retain E
const [a, b] = InMemoryTransport.createLinkedPair();
await server.connect(b);
await client.connect(a);
const rejected = await client.callTool({
name: "inspect_managed_artifact",
arguments: { path: join(directory, "absent.dll"), detail: "summary" },
});
expect(parseMcpToolError(rejected).error.code).toBe("capability_unavailable");
for (const name of ["inspect_managed_artifact", "inspect_managed_members"]) {
const rejected = await client.callTool({
name,
arguments: { path: join(directory, "absent.dll"), detail: "summary" },
});
expect(parseMcpToolError(rejected).error.code).toBe(
"capability_unavailable",
);
}
const complete = await client.callTool({
name: "inspect_managed_artifact",
arguments: { path },
@@ -0,0 +1,200 @@
import { writeFile, unlink } from "node:fs/promises";
import { join } from "node:path";
import { Ajv2020 } from "ajv/dist/2020.js";
import { expect, it, onTestFinished } from "vitest";
import { composeBinarySession } from "../../../src/application/binary/BinarySessionComposition.js";
import { AnalysisProviderRegistry } from "../../../src/application/binary/AnalysisProviderRegistry.js";
import { inspectAnalysisView } from "../../../src/application/analysisView/AnalysisViewService.js";
import { ManagedStaticProvider } from "../../../src/dotnet/ManagedStaticProvider.js";
import { buildManagedPeFixture } from "../../../src/dotnet/ManagedPe.fixture.js";
import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js";
import {
analysisViewResultSchema,
managedMemberSummaryViewResultSchema,
} from "../../../src/domain/analysisView/analysisView.js";
import { managedMemberInspectionSchema } from "../../../src/domain/managed/managedArtifact.js";
import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const COLLECTIONS = [
"types",
"exported_types",
"fields",
"methods",
"member_refs",
"call_edges",
"field_accesses",
"user_strings",
] as const;
const createHarness = async () => {
const session = composeBinarySession(new AnalysisProviderRegistry([]), [
new ManagedStaticProvider(),
]);
const harness = await createApplicationMcpHarness({ session });
onTestFinished(harness.close);
return harness;
};
it("summarizes selected members and pages every original collection after the file disappears", async () => {
const directory = await createTestTempDirectory("rea-member-summary-");
const path = join(directory, "fixture.dll");
await writeFile(path, buildManagedPeFixture({ methodRowCount: 2 }));
const { client, session } = await createHarness();
const definition = (await client.listTools()).tools.find(
(x) => x.name === "inspect_managed_members",
);
if (definition?.outputSchema === undefined)
throw new Error("Managed member schemas are absent");
const outputSchema: Record<string, unknown> = definition.outputSchema;
const ajv = new Ajv2020({ strict: false, validateFormats: false });
expect(ajv.validateSchema(definition.inputSchema)).toBe(true);
expect(ajv.validateSchema(outputSchema)).toBe(true);
const type_names = ["Fixture.Program", "Missing.Type"];
expect(
ajv.validate(definition.inputSchema, {
path,
type_names,
detail: "summary",
}),
).toBe(true);
const reply = await client.callTool({
name: "inspect_managed_members",
arguments: { path, type_names, detail: "summary" },
});
expect(reply.isError).not.toBe(true);
expect(
ajv.validate(outputSchema, reply.structuredContent),
JSON.stringify(ajv.errors),
).toBe(true);
const receipt = parseEvidence(reply.structuredContent);
const view = managedMemberSummaryViewResultSchema.parse(
receipt.normalized_result,
);
const parent = session.evidenceById(view.parent_evidence_id);
if (parent === undefined)
throw new Error("Complete member source was not retained");
expect(parent.operation).toBe("inspect_managed_members");
expect(receipt.evidence_links).toEqual([parent.evidence_id]);
const report = managedMemberInspectionSchema.parse(parent.normalized_result);
expect(view.summary.selection).toEqual({
type_names,
unmatched_type_names: ["Missing.Type"],
});
expect(view.summary.coverage).toEqual(report.coverage);
expect(view.summary.identity_scope).toEqual(report.identity_scope);
expect(view.summary).not.toHaveProperty("methods");
expect(report.methods.length).toBeGreaterThan(0);
expect(report.member_refs.length).toBeGreaterThan(0);
for (const detail of [undefined, "complete"]) {
const complete = parseEvidence(
(
await client.callTool({
name: "inspect_managed_members",
arguments: { path, type_names, ...(detail ? { detail } : {}) },
})
).structuredContent,
);
expect(complete.operation).toBe("inspect_managed_members");
expect(complete.normalized_result).toEqual(parent.normalized_result);
}
const original = JSON.stringify(parent);
await unlink(path);
for (const collection of COLLECTIONS) {
const rows =
collection === "user_strings"
? report.user_strings?.literals
: report[collection];
if (rows == null) throw new Error(`${collection} was not examined`);
expect(view.summary.reported_counts[collection]).toBe(rows.length);
const paged: unknown[] = [];
for (let offset: number | null = 0; offset !== null;) {
const result = await client.callTool({
name: "inspect_analysis_view",
arguments: {
source: {
kind: "retained-evidence",
evidence_id: parent.evidence_id,
},
view: { kind: "managed-members", collection, offset, limit: 1 },
},
});
expect(result.isError).not.toBe(true);
const page = analysisViewResultSchema.parse(
parseEvidence(result.structuredContent).normalized_result,
);
if (page.kind !== "managed-members")
throw new Error("Unexpected page kind");
expect(page.page.collection).toBe(collection);
expect(page.selection).toEqual(report.selection);
expect(page.coverage.total).toBe(rows.length);
paged.push(...page.page.rows);
offset = page.coverage.next_offset;
}
// Method rows carry body facts that a looser row schema would strip.
expect(paged).toEqual(rows);
}
expect(JSON.stringify(session.evidenceById(parent.evidence_id))).toBe(
original,
);
const portable = inspectAnalysisView({
source: { kind: "inline", evidence: parent },
view: { kind: "summary" },
});
if (!portable.ok) throw portable.error;
expect(portable.value.normalized_result).toEqual(view);
}, 30000);
it("keeps collections that historical member Evidence never examined unknown", async () => {
const directory = await createTestTempDirectory("rea-member-historical-");
const path = join(directory, "fixture.dll");
await writeFile(path, buildManagedPeFixture());
const { client } = await createHarness();
const parent = parseEvidence(
(
await client.callTool({
name: "inspect_managed_members",
arguments: { path },
})
).structuredContent,
);
const {
exported_types: _exported,
user_strings: _strings,
selection: _selection,
...recorded
} = managedMemberInspectionSchema.parse(parent.normalized_result);
const historical = createEvidence(undefined, parent.provider, {
operation: parent.operation,
parameters: parent.parameters,
result: recorded,
limitations: parent.limitations,
authority: parent.authority,
});
const summary = inspectAnalysisView({
source: { kind: "inline", evidence: historical },
view: { kind: "summary" },
});
if (!summary.ok) throw summary.error;
const view = managedMemberSummaryViewResultSchema.parse(
summary.value.normalized_result,
);
expect(view.summary.reported_counts.exported_types).toBeNull();
expect(view.summary.reported_counts.user_strings).toBeNull();
expect(view.summary.user_strings).toBeNull();
expect(view.summary.selection).toBeNull();
expect(view.unknowns.join(" ")).toContain("no recorded subject");
for (const collection of ["exported_types", "user_strings"] as const)
expect(
inspectAnalysisView({
source: { kind: "inline", evidence: historical },
view: { kind: "managed-members", collection, offset: 0, limit: 1 },
}),
).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
expect(
inspectAnalysisView({
source: { kind: "inline", evidence: historical },
view: { kind: "managed", collection: "attributes", offset: 0, limit: 1 },
}),
).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
}, 30000);