diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index f7e44e17a..b0b5b8b33 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -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: "41" + version: "42" --- # REA @@ -104,6 +104,10 @@ 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"`; @@ -323,7 +327,8 @@ 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) -- Passive browser/Electron observation and static/runtime reconciliation: +- Browser/Electron observation (passive and scenario) 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) diff --git a/.agents/skills/reverse-engineer-anything/references/runtime-observation.md b/.agents/skills/reverse-engineer-anything/references/runtime-observation.md index 2d660d675..ba197ecb8 100644 --- a/.agents/skills/reverse-engineer-anything/references/runtime-observation.md +++ b/.agents/skills/reverse-engineer-anything/references/runtime-observation.md @@ -1,4 +1,4 @@ -# Passive browser and Electron observation +# Browser and Electron runtime 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,13 +7,14 @@ 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 browser work with `list_browser_targets`; start Electron runtime work with +Start passive browser work with `list_browser_targets`; start Electron runtime work with `list_electron_targets`. Inspect the target selected for the user's task. Observation -is passive: never claim REA clicked, navigated, evaluated page JavaScript, -invoked IPC, captured prior activity, or contained the page's network. +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. -Credentials, cookies, authorization headers, storage values, and raw -WebSocket/JSON bodies are deliberately absent. Local URLs retain ordinary query +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 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 @@ -21,6 +22,38 @@ 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 diff --git a/docs/client-configuration.md b/docs/client-configuration.md index 41d239681..b661983c9 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -271,6 +271,21 @@ The user-level MCP paths follow the editor's VS Code layout. Trae's official documentation describes the raw JSON editor without specifying those paths; loading this registration in Trae remains unverified. +## WorkBuddy + +For [WorkBuddy](https://www.workbuddy.cn/docs/workbuddy/From-Beginner-to-Expert-Guide/Function-Description/MCP-Guide), setup writes `mcpServers.rea` into the +global `~/.workbuddy/mcp.json` (a standard `mcpServers` JSON file at the user +home root on every platform). Entries carry `command`, `args`, and `env`; +stdio is the default transport. WorkBuddy discovers personal skills from +`~/.workbuddy/skills` (`%userprofile%\.workbuddy\skills` on Windows), so +setup installs its skill copy there. + +The global MCP path is a plain JSON file; setup preserves every other server +and top-level key when it upserts `rea`. WorkBuddy also reads a project-level +`/.workbuddy/mcp.json`, which setup does not touch: it registers the +user-level file only. Loading this registration and real REA tool calls +through WorkBuddy are verified. + ## ZCode For [ZCode](https://zcode.z.ai/en/docs/mcp-services), setup registers `rea` in the nested `mcp.servers` table of the diff --git a/docs/installation.md b/docs/installation.md index 7abefac3d..d2fcebdff 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -298,11 +298,12 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s | Qoder | `qoder` | | Factory Droid | `droid` | | Trae | `trae` | +| WorkBuddy | `workbuddy` | | ZCode | `zcode` | | DeepSeek Harness | `dsh` | -`dsh` and `kimi` require a release containing them. The registry check above does -not cover them. +`dsh`, `kimi`, and `workbuddy` require a release containing them. The registry +check above does not cover them. See [client configuration and troubleshooting](client-configuration.md) for configuration paths, profiles, trust policies, and schema-limit workarounds. diff --git a/docs/readme/README_ko.md b/docs/readme/README_ko.md index 1c1133254..e6affdb19 100644 --- a/docs/readme/README_ko.md +++ b/docs/readme/README_ko.md @@ -20,7 +20,7 @@ **[웹사이트](https://rea.tools/ko/) · [가이드](https://rea.tools/ko/guides/) · [사례](https://rea.tools/ko/showcase/)** -[빠른 시작](#빠른-시작) · [REA의 작동 방식](#rea의-작동-방식) · [분석할 수 있는 대상](#분석할-수-있는-대상) · [사례](#사례) · [자주 묻는 질문](#자주-묻는-질문) · [GNU objdump 및 Rizin 도구](../reverse-engineering-tools.md) · [문서](#문서) +[빠른 시작](#빠른-시작) · [REA의 작동 방식](#rea의-작동-방식) · [분석할 수 있는 대상](#분석할-수-있는-대상) · [사례](#사례) · [자주 묻는 질문](#자주-묻는-질문) · [문서](#문서) npx rea-agents setup @@ -56,7 +56,7 @@ REA는 에이전트를 분석 도구에 연결해 네이티브 바이너리, JavaScript와 Electron 앱, .NET 어셈블리, 웹사이트를 검사할 수 있게 합니다. 터미널에서도 같은 도구를 사용할 수 있습니다. 분석은 로컬에서 실행되며, 결과에는 각 결론의 근거와 한계가 포함됩니다. -설정 과정은 에이전트에 REA를 등록하고 해당 버전에 맞는 워크플로 지침을 설치합니다. 네이티브 분석에는 기존 Hopper 또는 Ghidra 설치를 사용할 수 있습니다. 승인하면 설정 과정에서 Hopper를 설치할 수도 있습니다. 정적 JavaScript 분석에는 두 엔진 모두 필요하지 않습니다. +설정 과정은 에이전트에 REA를 등록하고 해당 버전에 맞는 워크플로 지침을 설치합니다. 네이티브 분석에는 기존 Hopper, Ghidra 또는 IDA 설치를 사용할 수 있습니다. 승인하면 설정 과정에서 Hopper를 설치할 수도 있습니다. 정적 JavaScript 분석에는 네이티브 분석 엔진이 필요하지 않습니다. > **[REA 웹사이트](https://rea.tools/ko/)**에서 설정 방법, 그림으로 설명한 가이드, 실제 사례를 확인하세요. @@ -105,12 +105,14 @@ rea --help REA는 빠르게 바뀌며 새 릴리스에는 버그 수정이 자주 포함됩니다. 설치된 버전을 최신으로 유지하세요. -npm으로 설치한 CLI에서는 다음 명령을 사용합니다. +`npm install --global` 또는 `bun add --global`로 설치한 CLI에서는 다음 명령을 사용합니다. ```bash rea update ``` +Bun 전역 업데이트에는 Bun 1.2.17 이상이 필요합니다. 이전 버전의 Bun을 사용한다면 `rea update`를 실행하기 전에 Bun을 직접 업그레이드하세요. + 에이전트 등록과 스킬을 갱신하려면 업데이트 과정에서 출력된 설정 명령을 실행하세요. `npx`를 사용한다면 다음 명령으로 에이전트 설정을 갱신하세요. @@ -119,7 +121,7 @@ rea update npx rea-agents@latest setup ``` -설정 변경 사항을 검토하고 에이전트를 다시 시작하세요. 일회성 CLI 작업에는 `npx rea-agents@latest` 뒤에 필요한 명령을 붙여 실행하세요. +설정 변경 사항을 검토하고 에이전트를 다시 시작하세요. 일회성 CLI 작업에는 `npx rea-agents@latest` 뒤에 필요한 명령을 붙여 실행하세요. Bun을 사용한다면 위 명령의 `npx rea-agents@latest`를 `bunx rea-agents@latest`로 바꿔 실행하세요. ## REA의 작동 방식 @@ -139,14 +141,16 @@ 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) | -| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | +| SQLite 데이터베이스 | 테이블, 열, 인덱스, 뷰, 트리거, 선택한 일반 테이블의 행 | 로컬 데이터베이스/WAL 스냅샷; Node.js 24.15 이상의 24.x 또는 26 이상(네이티브 SQLite 한도 적용) | | 기록된 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; mitmproxy 네이티브 캡처에는 Linux의 mitmdump; [캡처 가이드](../web-network-captures.md) | -| 패킷 캡처 | 엔드포인트, DNS, TLS SNI, 프로토콜 분포, HTTP 요청 | 호출자 제공 tshark 및 capinfos; [pcap 가이드](../pcap-analysis.md) | -| .NET 어셈블리 | 메타데이터, CIL 명령, 선언된 네이티브 의존성, 빌드 비교 | 정적 검사; [관리 코드 가이드](../managed-code-analysis.md) | +| 저장된 네트워크 캡처 | 요청, 응답, 접근 가능한 페이로드, 소스 위치 | HAR 또는 Fiddler Everywhere SAZ; mitmproxy 네이티브 캡처에는 Linux의 mitmdump; [캡처 가이드](../web-network-captures.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) | @@ -154,15 +158,18 @@ 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을 설치하거나 업데이트하지 않습니다. + -지원하는 네이티브 형식과 호스트는 제공자마다 다릅니다. [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)를 확인하세요. +지원하는 네이티브 형식과 호스트는 제공자마다 다릅니다. [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)를 확인하세요. ## 사례 @@ -209,7 +216,7 @@ REA로 흥미로운 대상을 분석했다면 알려 주세요. [issue](https://
Hopper를 먼저 실행해야 하나요? -REA는 작업에 필요할 때 Hopper를 실행합니다. macOS에서는 처음 실행할 때 데모 모드를 선택하거나 라이선스를 활성화하라는 대화 상자가 나타날 수 있습니다. [Hopper 실행 및 문제 해결](../installation.md#launcher-paths-and-troubleshooting)을 참고하세요. +REA는 작업에 필요할 때 Hopper를 실행합니다. macOS에서는 처음 실행할 때 데모 모드를 선택하거나 라이선스를 활성화하라는 대화 상자가 나타날 수 있습니다. 첫 CLI/MCP 작업이나 무인 작업을 실행하기 전에 같은 사용자 세션에서 이 대화 상자를 완료하세요. 필요하면 Hopper를 직접 여세요. [Hopper 실행 및 문제 해결](../installation.md#launcher-paths-and-troubleshooting)을 참고하세요.
@@ -239,7 +246,7 @@ REA는 대상을 로컬에서 분석합니다. 에이전트는 도구 결과를 먼저 업데이트하세요. 최근 릴리스에서 이미 수정되었을 수 있습니다. -npm으로 설치한 CLI에서는 다음 명령을 사용합니다. +`npm install --global` 또는 `bun add --global`로 설치한 CLI에서는 다음 명령을 사용합니다. ```bash rea update @@ -286,11 +293,9 @@ 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는 법을 준수하는 연구, 교육, 상호운용성 확보, 소프트웨어 보존, 그리고 본인이 소유하거나 조사 권한을 가진 소프트웨어의 분석을 위한 도구입니다. ## 기여 @@ -302,14 +307,6 @@ REA에 도움을 보태 주세요! [issue를 등록](https://github.com/morluto/ [MIT](../../LICENSE) -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. +REA가 작성한 코드는 MIT 라이선스를 따릅니다. 제3자 자료에는 각자의 라이선스와 저작권 고지가 그대로 유지됩니다. 의존성 목록, 보존된 라이선스 원문, 외부 도구 약관은 [제3자 고지](../../THIRD_PARTY_NOTICES.md)를 참고하세요. -**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. +IDA Pro / Hex-Rays 디컴파일러, Hopper, JEB 등 상용 도구는 해당 라이선스에 따라 **합법적으로 취득하고 사용 권한을 갖춰야 합니다.** REA는 어떤 도구의 라이선스, 키, 활성화 권한도 부여하지 않습니다. 체험판과 데모는 사용자, 기능, 자동화, 출력 공유 제한을 포함한 공급업체 약관을 따릅니다. 크랙 버전, 도난 키, 무단 키 공유, 라이선스 제한 우회는 허용되지 않습니다. 이 안내는 MIT 라이선스나 제3자 라이선스를 변경하지 않습니다. diff --git a/scripts/lib/native-value-e2e.mjs b/scripts/lib/native-value-e2e.mjs index d329bc822..916533a87 100644 --- a/scripts/lib/native-value-e2e.mjs +++ b/scripts/lib/native-value-e2e.mjs @@ -215,6 +215,27 @@ async function verifyNativeValueAdapters( const functionEvidence = parseEvidence(analyzed.structuredContent); assert.equal(functionEvidence.operation, "analyze_function"); assert.equal(functionEvidence.provider.id, "ghidra"); + assert.deepEqual( + functionEvidence.normalized_result.facet_availability, + Object.fromEntries( + [ + "pseudocode", + "assembly", + "comments", + "callers", + "callees", + "references", + "strings_names", + "cfg", + ].map((facet) => [facet, { available: true }]), + ), + ); + assert.equal(functionEvidence.relationship_coverage?.status, "complete"); + assert.ok( + functionEvidence.relationship_coverage.diagnostics.some((diagnostic) => + diagnostic.includes("indirect calls remain unknown"), + ), + ); const functionProfile = analysisProfileSchema.parse( functionEvidence.analysis_profile, ); @@ -247,6 +268,7 @@ async function verifyNativeValueAdapters( assert.ok( comparisonEvidence.evidence_links.includes(functionEvidence.evidence_id), ); + await verifyUnresolvedCallEvidence(client); const bundle = await client.callTool({ name: "get_evidence_bundle", arguments: {}, @@ -307,3 +329,63 @@ async function verifyNativeValueAdapters( unknowns: cliGraph.unknowns.length, }; } + +async function verifyUnresolvedCallEvidence(client) { + const inventory = await client.callTool({ + name: "list_procedures", + arguments: {}, + }); + assert.notEqual(inventory.isError, true, JSON.stringify(inventory)); + const procedures = parseEvidence( + inventory.structuredContent, + ).normalized_result; + const indirect = procedures.find((item) => + item.value.endsWith("rea_ghidra_inventory_indirect"), + ); + const leaf = procedures.find((item) => + item.value.endsWith("rea_ghidra_inventory_leaf"), + ); + assert.ok( + indirect && leaf, + "Source fixture lacks the unresolved callback and its possible target", + ); + const analyzed = await client.callTool( + { name: "analyze_function", arguments: { procedure: indirect.address } }, + { timeout: 180000 }, + ); + assert.notEqual(analyzed.isError, true, JSON.stringify(analyzed)); + const evidence = parseEvidence(analyzed.structuredContent); + assert.equal(evidence.relationship_coverage?.status, "partial"); + assert.ok(evidence.normalized_result.unresolved_calls.length > 0); + const traced = await client.callTool( + { + name: "trace_call_path", + arguments: { + start: indirect.address, + goal: leaf.address, + direction: "forward", + }, + }, + { timeout: 180000 }, + ); + assert.notEqual(traced.isError, true, JSON.stringify(traced)); + const trace = parseEvidence(traced.structuredContent).normalized_result; + assert.equal(trace.goal_status, "unknown"); + assert.equal(trace.truncated, true); + assert.ok(trace.residual_unknowns.length > 0); + const compared = await client.callTool( + { + name: "compare_functions", + arguments: { + left: analyzed.structuredContent, + right: analyzed.structuredContent, + }, + }, + { timeout: 180000 }, + ); + assert.notEqual(compared.isError, true, JSON.stringify(compared)); + assert.equal( + parseEvidence(compared.structuredContent).normalized_result.status, + "unknown", + ); +} diff --git a/scripts/lib/real-ghidra-boundaries.mjs b/scripts/lib/real-ghidra-boundaries.mjs index c2347dde1..54376c389 100644 --- a/scripts/lib/real-ghidra-boundaries.mjs +++ b/scripts/lib/real-ghidra-boundaries.mjs @@ -136,7 +136,7 @@ export async function verifyGhidraBoundaries( assert.deepEqual(externalDossier.callees, []); assert.deepEqual(externalDossier.facet_availability.pseudocode, { available: false, - reason: "Ghidra did not supply pseudocode.", + reason: "Ghidra does not decompile external functions.", }); for (const facet of [ "assembly", diff --git a/src/application/ReferenceSourceImport.ts b/src/application/ReferenceSourceImport.ts index e07a38146..0a7c27265 100644 --- a/src/application/ReferenceSourceImport.ts +++ b/src/application/ReferenceSourceImport.ts @@ -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 { - ReferenceSourceEntryKind, + ReferenceSourceExclusionKind, ReferenceSourceRead, ReferenceSourceReaderError, } from "../reference/ReferenceSourceReaderTypes.js"; @@ -111,7 +111,8 @@ 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 === "symlink" && entry.target_state !== "internal") || + entry.kind === "unknown", ); if (partialEntry) return "partial"; const partialRelationship = input.relationships.some( @@ -142,11 +143,24 @@ const createShouldExclude = exclusions: HistoricalSourceGraphInput["exclusions"], secrets: PreparedReferenceSourceImport["secrets"], ignored: PreparedReferenceSourceImport["ignored"], - ): ((path: string, kind: ReferenceSourceEntryKind) => boolean) => + ): ((path: string, kind: ReferenceSourceExclusionKind) => boolean) => (path, kind) => { - const patternPath = kind === "directory" ? `${path}/` : path; - const secretMatch = secrets.test(patternPath); - if (secretMatch.ignored) { + // 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) { if (!secretMatch.rule) throw new Error(`Ignored secret path has no matching rule: ${path}`); exclusions.push({ @@ -157,8 +171,8 @@ const createShouldExclude = }); return true; } - const match = ignored.test(patternPath); - if (!match.ignored) return false; + const match = firstMatch(ignored); + if (match?.ignored !== true) return false; if (!match.rule) throw new Error(`Ignored path has no matching rule: ${path}`); const reason = match.rule.mark; diff --git a/src/application/ReferenceSourceImportEntries.ts b/src/application/ReferenceSourceImportEntries.ts index 49c6f82f5..29dbef356 100644 --- a/src/application/ReferenceSourceImportEntries.ts +++ b/src/application/ReferenceSourceImportEntries.ts @@ -45,6 +45,8 @@ 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."; }; @@ -73,6 +75,13 @@ 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", diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts index 582c48ba7..c4da675e7 100644 --- a/src/application/SupportedClients.ts +++ b/src/application/SupportedClients.ts @@ -607,6 +607,14 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ markerPath: traeUserDirectory, format: "json", }, + { + name: "workbuddy", + displayName: "WorkBuddy", + skillPath: [".workbuddy", "skills"], + configPath: [".workbuddy", "mcp.json"], + markerPath: [".workbuddy"], + format: "json", + }, { name: "grok_build", displayName: "Grok Build", diff --git a/src/domain/callPath.ts b/src/domain/callPath.ts index 65524fbc2..3b8bd1cfe 100644 --- a/src/domain/callPath.ts +++ b/src/domain/callPath.ts @@ -1,3 +1,4 @@ +import { relationshipCoverageLimitations } from "./relationshipCoverage.js"; import { callPathInputSchema, callPathResultSchema, @@ -240,15 +241,12 @@ const inspectSearch = ({ const coverage = snapshot.evidence.relationship_coverage; for (const limitation of snapshot.limitations) limitations.push(limitation); - if (coverage?.status === "partial") - for (const limitation of coverage.limitations) + if (coverage !== null) { + for (const limitation of relationshipCoverageLimitations(coverage)) limitations.push(limitation); - else if (coverage?.status === "unknown") - limitations.push(coverage.reason); - for (const diagnostic of coverage !== null && "diagnostics" in coverage - ? coverage.diagnostics - : []) - limitations.push(diagnostic); + for (const diagnostic of coverage.diagnostics ?? []) + limitations.push(diagnostic); + } // The requested goal is a terminal for this search. Its outgoing edges // cannot add a path to the goal, so its dossier limits are contextual // but do not make the searched start-to-goal closure incomplete. diff --git a/src/domain/functionCallCoverage.ts b/src/domain/functionCallCoverage.ts index ce3798c4f..06fc06fe5 100644 --- a/src/domain/functionCallCoverage.ts +++ b/src/domain/functionCallCoverage.ts @@ -1,4 +1,7 @@ -import type { RelationshipCoverage } from "./relationshipCoverage.js"; +import { + relationshipCoverageLimitations, + type RelationshipCoverage, +} from "./relationshipCoverage.js"; import type { FunctionDossier } from "./hopperValues.js"; import { uniqueSorted } from "./canonicalOrdering.js"; @@ -15,11 +18,7 @@ export const combineFunctionRelationshipCoverage = ( { direction: "callees" as const, coverage: callees }, ]; const diagnostics = uniqueSorted( - reads.flatMap(({ coverage }) => - coverage !== undefined && "diagnostics" in coverage - ? coverage.diagnostics - : [], - ), + reads.flatMap(({ coverage }) => coverage?.diagnostics ?? []), ); const details = diagnostics.length > 0 ? { diagnostics } : {}; const callersLimited = callers?.status !== "complete"; @@ -35,15 +34,9 @@ export const combineFunctionRelationshipCoverage = ( const reasons = reads.flatMap(({ direction, coverage }) => { const label = direction === "callers" ? callerCoveragePrefix : calleeCoveragePrefix; - const limitations = - coverage === undefined - ? ["Provider did not report relationship coverage."] - : coverage.status === "unknown" - ? [coverage.reason] - : coverage.status === "partial" - ? coverage.limitations - : []; - return limitations.map((reason) => `${label}${reason}`); + return relationshipCoverageLimitations(coverage).map( + (reason) => `${label}${reason}`, + ); }); if ( reads.some( @@ -90,24 +83,11 @@ export const functionCallCoverageLimitations = ( coverage: RelationshipCoverage | null, dossier: Pick, ): string[] => { - const limitations = - coverage === null - ? ["Provider did not report relationship coverage."] - : coverage.status === "unknown" - ? [ - coverage.reason, - ...("diagnostics" in coverage ? coverage.diagnostics : []), - ] - : coverage.status === "partial" - ? [ - ...coverage.limitations, - ...("diagnostics" in coverage ? coverage.diagnostics : []), - ] - : []; - limitations.push( + return uniqueSorted([ + ...relationshipCoverageLimitations(coverage), + ...(coverage?.status === "complete" ? [] : (coverage?.diagnostics ?? [])), ...(dossier.unresolved_calls ?? []).map( ({ address, reason }) => `Unresolved call at ${address}: ${reason}`, ), - ); - return uniqueSorted(limitations); + ]); }; diff --git a/src/domain/javascript/javascriptStaticAnalysis.event_source.test.ts b/src/domain/javascript/javascriptStaticAnalysis.event_source.test.ts new file mode 100644 index 000000000..0fa5d6543 --- /dev/null +++ b/src/domain/javascript/javascriptStaticAnalysis.event_source.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +const endpoints = (source: string) => + analyzeJavaScriptStaticSource(source).endpoints.map( + ({ kind, value, mechanism }) => ({ kind, value, mechanism }), + ); + +describe("server-sent event endpoints", () => { + it("reports EventSource URLs like WebSocket URLs", () => { + expect( + endpoints(` + new WebSocket("wss://chat.example/ws"); + new EventSource("/api/chat/stream"); + new window.EventSource("https://chat.example/events", { withCredentials: true }); + `), + ).toEqual([ + { + kind: "network", + value: "wss://chat.example/ws", + mechanism: "call:WebSocket", + }, + { + kind: "network", + value: "/api/chat/stream", + mechanism: "call:EventSource", + }, + { + kind: "network", + value: "https://chat.example/events", + mechanism: "call:window.EventSource", + }, + ]); + }); + + it("keeps a dynamic EventSource URL unreported", () => { + expect(endpoints("new EventSource(url);")).toEqual([]); + }); +}); diff --git a/src/domain/javascript/javascriptStaticAnalysis.send_beacon.test.ts b/src/domain/javascript/javascriptStaticAnalysis.send_beacon.test.ts new file mode 100644 index 000000000..fd26b88b5 --- /dev/null +++ b/src/domain/javascript/javascriptStaticAnalysis.send_beacon.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +const endpoints = (source: string) => + analyzeJavaScriptStaticSource(source).endpoints.map( + ({ kind, value, mechanism }) => ({ kind, value, mechanism }), + ); + +describe("beacon endpoints", () => { + it("reports navigator.sendBeacon URLs", () => { + expect( + endpoints(` + navigator.sendBeacon("/api/telemetry", payload); + window.navigator.sendBeacon("https://metrics.example/collect"); + `), + ).toEqual([ + { + kind: "network", + value: "/api/telemetry", + mechanism: "call:navigator.sendBeacon", + }, + { + kind: "network", + value: "https://metrics.example/collect", + mechanism: "call:window.navigator.sendBeacon", + }, + ]); + }); + + it("keeps a dynamic beacon URL unreported", () => { + expect(endpoints("navigator.sendBeacon(url, payload);")).toEqual([]); + }); +}); diff --git a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts index e2cc73f20..14827e1c2 100644 --- a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts +++ b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts @@ -554,7 +554,13 @@ export const endpointArgument = ( receiverFact?: JavaScriptOpenReceiverFact, ): string | undefined => { if (receiverFact === "window") return undefined; - if (name === "fetch" || name.endsWith(".fetch") || name === "WebSocket") + if ( + name === "fetch" || + name.endsWith(".fetch") || + name === "WebSocket" || + name === "EventSource" || + name.endsWith(".EventSource") + ) return stringValue(args[0]); if (name.endsWith(".open")) return xhrOpenUrl(name, args[0], args[1], receiverFact); @@ -574,6 +580,9 @@ export const endpointArgument = ( ? undefined : value; } + // navigator.sendBeacon(url, data) queues a POST to its first argument. + if (name === "sendBeacon" || name.endsWith(".sendBeacon")) + return stringValue(args[0]); if (name.endsWith("loadURL")) return stringValue(args[0]); return undefined; }; diff --git a/src/domain/referenceSourceGraph.test.ts b/src/domain/referenceSourceGraph.test.ts index 272c2f62c..5458b984a 100644 --- a/src/domain/referenceSourceGraph.test.ts +++ b/src/domain/referenceSourceGraph.test.ts @@ -259,6 +259,30 @@ 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"; @@ -473,6 +497,16 @@ describe("historical source graph excluded symlink targets", () => { /recorded exclusion/u, ); + input.exclusions[0] = { + path: "secret.txt", + entry_kind: "unknown", + reason: "configured-secret", + pattern: "secret.txt", + }; + expect(() => createHistoricalSourceGraph(input)).toThrow( + /recorded exclusion/u, + ); + input.exclusions[0] = { path: "secret.txt", reason: "configured-secret", diff --git a/src/domain/referenceSourceGraph.ts b/src/domain/referenceSourceGraph.ts index 0c1c7caaf..a10213e87 100644 --- a/src/domain/referenceSourceGraph.ts +++ b/src/domain/referenceSourceGraph.ts @@ -110,6 +110,10 @@ const sourceEntrySchema = z.union([ sourceFileSchema, sourceDirectorySchema, sourceSymlinkSchema, + z.strictObject({ + ...entryBaseShape, + kind: z.literal("unknown"), + }), ]); const sourceRelationshipSchema = z.strictObject({ @@ -130,7 +134,7 @@ const exclusionSchema = z.discriminatedUnion("reason", [ z.strictObject({ path: relativePathSchema, entry_kind: z - .enum(["file", "directory", "symlink", "other"]) + .enum(["file", "directory", "symlink", "other", "unknown"]) .exactOptional(), reason: z.enum([ "configured-secret", @@ -143,7 +147,7 @@ const exclusionSchema = z.discriminatedUnion("reason", [ z.strictObject({ path: relativePathSchema, entry_kind: z - .enum(["file", "directory", "symlink", "other"]) + .enum(["file", "directory", "symlink", "other", "unknown"]) .exactOptional(), reason: z.enum(["symlink-escape", "unreadable"]), }), @@ -330,7 +334,7 @@ const checkSymlinks = ( ); const excludedKinds = new Map< string, - Set<"file" | "directory" | "symlink" | "other" | undefined> + Set<"file" | "directory" | "symlink" | "other" | "unknown" | undefined> >(); for (const { path, entry_kind } of graph.exclusions) { const kinds = excludedKinds.get(path) ?? new Set(); @@ -399,7 +403,8 @@ 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 === "symlink" && entry.target_state !== "internal") || + entry.kind === "unknown", ); const partialRelationship = graph.relationships.some( ({ parse_state, resolution }) => @@ -516,6 +521,11 @@ const rootCommitment = ( target: entry.target, target_state: entry.target_state, }; + case "unknown": + return { + kind: entry.kind, + path: entry.path, + }; } }), exclusions, diff --git a/src/domain/referenceSourceSummary.test.ts b/src/domain/referenceSourceSummary.test.ts index a0d23b0e1..3f34a073f 100644 --- a/src/domain/referenceSourceSummary.test.ts +++ b/src/domain/referenceSourceSummary.test.ts @@ -81,6 +81,26 @@ 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( diff --git a/src/domain/referenceSourceSummary.ts b/src/domain/referenceSourceSummary.ts index e7e99e3fa..55d24025d 100644 --- a/src/domain/referenceSourceSummary.ts +++ b/src/domain/referenceSourceSummary.ts @@ -29,6 +29,7 @@ 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, @@ -59,7 +60,8 @@ export const createHistoricalSourceSummary = ( } else if (entry.kind === "directory") { counts.directories += 1; treeStates[entry.tree_state] += 1; - } else counts.symlinks += 1; + } else if (entry.kind === "symlink") counts.symlinks += 1; + else counts.unknown += 1; // Canonical entries already use Unicode code-point order; filtering preserves it. if (entry.path.split("/").length <= outlineDepth) outline.push(outlineEntry(entry)); diff --git a/src/domain/relationshipCoverage.ts b/src/domain/relationshipCoverage.ts index 4dd4e0c40..f241206a0 100644 --- a/src/domain/relationshipCoverage.ts +++ b/src/domain/relationshipCoverage.ts @@ -33,3 +33,19 @@ export const relationshipCoverageSchema = z.discriminatedUnion("status", [ ]); export type RelationshipCoverage = z.infer; + +/** Reasons the observed relationship inventory cannot establish completeness. */ +export const relationshipCoverageLimitations = ( + coverage: RelationshipCoverage | null | undefined, +): readonly string[] => { + switch (coverage?.status) { + case undefined: + return ["Provider did not report relationship coverage."]; + case "complete": + return []; + case "partial": + return coverage.limitations; + case "unknown": + return [coverage.reason]; + } +}; diff --git a/src/generatedPackageMetadata.ts b/src/generatedPackageMetadata.ts index bada60663..081e22d5a 100644 --- a/src/generatedPackageMetadata.ts +++ b/src/generatedPackageMetadata.ts @@ -5,5 +5,5 @@ export const PACKAGE_METADATA = { serverSdkVersion: "2.3.1", clientSdkVersion: "2.3.1", coreSdkVersion: "2.3.1", - skillVersion: "41", + skillVersion: "42", } as const; diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index 24158e4a1..f7a5f711b 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -40,6 +40,8 @@ import { NATIVE_AOT_PROFILE_CONTRACT_REVISION, } from "../domain/native/nativeAotPe.js"; +const callRelationshipCoverage = "function-body-call-coverage-v5"; + /** * The processor language REA pins for this target, or undefined when Ghidra may * select from the header. The committed profile and the session readback both @@ -125,7 +127,7 @@ export const resolveGhidraAnalysisProfile = async ( load_image_observations: "source-mappings-entry-context-v2", function_body_evidence: "complete-inclusive-ranges-v1", function_references: "complete-body-and-entry-reference-manager-v2", - call_relationship_coverage: "function-body-call-coverage-v5", + call_relationship_coverage: callRelationshipCoverage, function_dossier_coverage: "observed-facets-and-call-relationships-v1", string_inventory_evidence: "defined-data-coverage-v1", @@ -223,7 +225,7 @@ export const resolveGhidraAnalysisProfile = async ( load_image_observations: "source-mappings-entry-context-v2", function_body_evidence: "complete-inclusive-ranges-v1", function_references: "complete-body-and-entry-reference-manager-v2", - call_relationship_coverage: "function-body-call-coverage-v5", + call_relationship_coverage: callRelationshipCoverage, function_dossier_coverage: "observed-facets-and-call-relationships-v1", string_inventory_evidence: "defined-data-coverage-v1", location_resolution: "explicit-address-exact-entry-symbol-first-v3", diff --git a/src/reference/ReferenceSourceReaderEntries.ts b/src/reference/ReferenceSourceReaderEntries.ts index e684a2b5c..8ec5e9d80 100644 --- a/src/reference/ReferenceSourceReaderEntries.ts +++ b/src/reference/ReferenceSourceReaderEntries.ts @@ -25,7 +25,7 @@ import { import type { PendingDirectory, ReferenceSourceEntry, - ReferenceSourceEntryKind, + ReferenceSourceExclusionKind, ReferenceSourceResult, TraversalState, } from "./ReferenceSourceReaderTypes.js"; @@ -201,6 +201,13 @@ 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 }; } @@ -371,10 +378,10 @@ const readSymlinkTarget = async ( const applyExclusion = ( shouldExclude: - | ((path: string, kind: ReferenceSourceEntryKind) => boolean) + | ((path: string, kind: ReferenceSourceExclusionKind) => boolean) | undefined, path: string, - kind: ReferenceSourceEntryKind, + kind: ReferenceSourceExclusionKind, ): { readonly ok: true; readonly value: boolean } | { readonly ok: false } => { try { return { ok: true, value: shouldExclude?.(path, kind) === true }; diff --git a/src/reference/ReferenceSourceReaderErrors.ts b/src/reference/ReferenceSourceReaderErrors.ts index 4444b43ba..2c826b4c9 100644 --- a/src/reference/ReferenceSourceReaderErrors.ts +++ b/src/reference/ReferenceSourceReaderErrors.ts @@ -47,6 +47,8 @@ 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; diff --git a/src/reference/ReferenceSourceReaderTypes.ts b/src/reference/ReferenceSourceReaderTypes.ts index 3b0a368b5..1fec38313 100644 --- a/src/reference/ReferenceSourceReaderTypes.ts +++ b/src/reference/ReferenceSourceReaderTypes.ts @@ -10,11 +10,17 @@ 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: ReferenceSourceEntryKind, + kind: ReferenceSourceExclusionKind, ) => boolean; } @@ -84,7 +90,7 @@ export type TraversalState = { readonly signal?: AbortSignal; readonly shouldExclude?: ( path: string, - kind: ReferenceSourceEntryKind, + kind: ReferenceSourceExclusionKind, ) => boolean; readonly entries: ReferenceSourceEntry[]; readonly pending: PendingDirectory[]; diff --git a/tests/boundary/filesystem/clientLifecycle.test.ts b/tests/boundary/filesystem/clientLifecycle.test.ts index 0e4054ae6..877ae92e8 100644 --- a/tests/boundary/filesystem/clientLifecycle.test.ts +++ b/tests/boundary/filesystem/clientLifecycle.test.ts @@ -67,6 +67,7 @@ describe("client configuration filesystem lifecycle", () => { "kimi", "vscode", "trae", + "workbuddy", "grok_build", "omp", "pi", diff --git a/tests/boundary/filesystem/referenceSourceFailureDetails.test.ts b/tests/boundary/filesystem/referenceSourceFailureDetails.test.ts index d379f01c6..56b66f16a 100644 --- a/tests/boundary/filesystem/referenceSourceFailureDetails.test.ts +++ b/tests/boundary/filesystem/referenceSourceFailureDetails.test.ts @@ -173,6 +173,16 @@ describe("reference import paths beyond the platform limit", () => { 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( @@ -187,6 +197,23 @@ describe("reference import paths beyond the platform limit", () => { 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, + entry_kind: "unknown", + reason: "configured-secret", + pattern: secretPattern, + }); } finally { await deepTree("remove"); } diff --git a/tests/boundary/filesystem/workbuddyClientConfiguration.test.ts b/tests/boundary/filesystem/workbuddyClientConfiguration.test.ts new file mode 100644 index 000000000..b6aac6577 --- /dev/null +++ b/tests/boundary/filesystem/workbuddyClientConfiguration.test.ts @@ -0,0 +1,159 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; +import { npxRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; +import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js"; +import { skillDestinations } from "../../../src/application/SetupSkill.js"; +import { supportedClients } from "../../../src/application/SupportedClients.js"; +import { systemUninstallHost } from "../../../src/application/Uninstall.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js"; + +/** + * The registration command setup writes on the host running the tests. Windows + * wraps it in `cmd.exe /d /c`, so the expected entry follows the host too. + */ +const command = npxRegistrationCommand(process.platform); + +beforeEach(clearClientLocationEnvironment); +afterEach(() => vi.unstubAllEnvs()); + +const workbuddyPath = ( + home: string, + platform: NodeJS.Platform = process.platform, + env: Parameters[2] = {}, +) => + supportedClients(home, platform, env).find( + ({ name }) => name === "workbuddy", + ); + +describe("WorkBuddy configuration paths", () => { + it("resolves the global mcp.json under the home root", () => { + expect(workbuddyPath("/home/a")?.configPath).toBe( + join("/home/a", ".workbuddy", "mcp.json"), + ); + expect(workbuddyPath("/Users/a")?.markerPath).toBe( + join("/Users/a", ".workbuddy"), + ); + expect(workbuddyPath("C:\\Users\\a", "win32")?.configPath).toBe( + join("C:\\Users\\a", ".workbuddy", "mcp.json"), + ); + }); + + it("resolves the registration format as the plain mcpServers JSON dialect", () => { + expect(workbuddyPath("/home/a")?.format).toBe("json"); + }); + + it("resolves the personal skills directory under the home root", () => { + expect( + skillDestinations("/home/a", ["workbuddy"]).map(({ path }) => path), + ).toEqual([ + join("/home/a", ".workbuddy", "skills", "reverse-engineer-anything"), + ]); + expect( + skillDestinations("C:\\Users\\a", ["workbuddy"], {}, "win32").map( + ({ path }) => path, + ), + ).toEqual([ + join("C:\\Users\\a", ".workbuddy", "skills", "reverse-engineer-anything"), + ]); + }); +}); + +describe("WorkBuddy JSON registration", () => { + const workbuddyClient = async () => { + const home = await createTestTempDirectory("rea-workbuddy-"); + const client = workbuddyPath(home, process.platform); + if (client?.markerPath === undefined) + throw new Error("missing WorkBuddy client"); + await mkdir(client.markerPath, { recursive: true }); + return { home, client }; + }; + + it("upserts rea into mcpServers while preserving unrelated settings", async () => { + const { home, client } = await workbuddyClient(); + await writeFile( + client.configPath, + `{ + "mcpServers": { + "other": { "command": "other-server" } + } +}`, + ); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const parsed = JSON.parse(await readFile(client.configPath, "utf8")); + expect(parsed.mcpServers.other).toEqual({ command: "other-server" }); + expect(parsed.mcpServers.rea).toEqual({ + command: command[0], + args: command.slice(1), + }); + + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "unchanged", + }); + expect( + ( + await readClientRegistrationStatuses(home, undefined, { + platform: process.platform, + environment: {}, + }) + ).find(({ client }) => client === "workbuddy"), + ).toMatchObject({ state: "aligned", command }); + }); + + it("creates the mcpServers table in an existing mcp.json without servers", async () => { + const { client } = await workbuddyClient(); + await writeFile( + client.configPath, + JSON.stringify({ unrelated: { setting: true } }, null, 2), + ); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const parsed = JSON.parse(await readFile(client.configPath, "utf8")); + expect(parsed.unrelated).toEqual({ setting: true }); + expect(parsed.mcpServers.rea).toEqual({ + command: command[0], + args: command.slice(1), + }); + }); + + it("registers into an empty mcp.json", async () => { + const { client } = await workbuddyClient(); + await writeFile(client.configPath, ""); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const parsed = JSON.parse(await readFile(client.configPath, "utf8")); + expect(parsed.mcpServers.rea).toEqual({ + command: command[0], + args: command.slice(1), + }); + }); + + it("removes only the rea entry on uninstall", async () => { + const { home, client } = await workbuddyClient(); + await writeFile( + client.configPath, + JSON.stringify( + { mcpServers: { other: { command: "other-server" } } }, + null, + 2, + ), + ); + await configureClientConfiguration(client, {}, command); + + expect((await systemUninstallHost(home).removeClient(client)).status).toBe( + "removed", + ); + const parsed = JSON.parse(await readFile(client.configPath, "utf8")); + expect(parsed.mcpServers.other).toEqual({ command: "other-server" }); + expect(parsed.mcpServers.rea).toBeUndefined(); + }); +}); diff --git a/tests/boundary/mcp/ghidraEvidenceMcp.test.ts b/tests/boundary/mcp/ghidraEvidenceMcp.test.ts index f7643f337..d27c6d8c3 100644 --- a/tests/boundary/mcp/ghidraEvidenceMcp.test.ts +++ b/tests/boundary/mcp/ghidraEvidenceMcp.test.ts @@ -15,9 +15,17 @@ import { type GhidraFunctionOperation, } from "../../../src/ghidra/GhidraFunctionValues.js"; -it.each([true, false])( - "compares observed empty Ghidra facets while preserving pseudocode availability (%s)", - async (pseudocodeAvailable) => { +it.each([ + { pseudocodeAvailable: true, reportsCoverage: true }, + { pseudocodeAvailable: false, reportsCoverage: true }, + { pseudocodeAvailable: true, reportsCoverage: false }, + { pseudocodeAvailable: false, reportsCoverage: false }, +])( + "compares observed empty Ghidra facets with pseudocode=$pseudocodeAvailable and coverage=$reportsCoverage", + async ({ pseudocodeAvailable, reportsCoverage }) => { + const coverage = reportsCoverage + ? { status: "complete" as const } + : undefined; const dossier = functionDossierSchema.parse(ghidraFunctionDossier()); Object.assign(dossier, { comments: [], @@ -47,7 +55,13 @@ it.each([true, false])( if (operation !== "analyze_function") throw new Error(`Unexpected operation: ${operation}`); return Promise.resolve( - ok({ value: jsonValueSchema.parse(dossier), limitations: [] }), + ok({ + value: jsonValueSchema.parse(dossier), + limitations: [], + ...(coverage === undefined + ? {} + : { relationship_coverage: coverage }), + }), ); }, ); @@ -61,9 +75,7 @@ it.each([true, false])( harness.session, analyzed.structuredContent, ); - expect(evidence.relationship_coverage).toMatchObject({ - status: "complete", - }); + expect(evidence.relationship_coverage).toEqual(coverage ?? null); expect(evidence.normalized_result).toEqual(dossier); const reply = await harness.mcp.callTool({ name: "compare_functions", @@ -86,7 +98,7 @@ it.each([true, false])( pseudocode: pseudocodeAvailable ? "unchanged" : "unknown", assembly: "unchanged", comments: "unchanged", - calls: "unchanged", + calls: reportsCoverage ? "unchanged" : "unknown", references: "unchanged", strings_names: "unchanged", cfg: "unchanged", @@ -96,9 +108,7 @@ it.each([true, false])( evidence.parameters, ); if (!replay.ok) throw replay.error; - expect(replay.value.relationshipCoverage).toEqual( - evidence.relationship_coverage, - ); + expect(replay.value.relationshipCoverage).toEqual(coverage); expect(harness.calls).toEqual(["analyze_function"]); } finally { await harness.close();