diff --git a/bridge/ghidra/ReaGhidraBridge.java b/bridge/ghidra/ReaGhidraBridge.java index 7c90d2e4a..84e809ccb 100644 --- a/bridge/ghidra/ReaGhidraBridge.java +++ b/bridge/ghidra/ReaGhidraBridge.java @@ -28,6 +28,7 @@ import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; +import java.util.HashMap; import java.util.HashSet; import java.util.IdentityHashMap; import java.util.Iterator; @@ -141,6 +142,7 @@ public final class ReaGhidraBridge extends HeadlessScript { "set_addresses_names", "inspect_native_load_image", "read_bytes", + "search_bytes", "address_to_file_offset", "ping", "shutdown", @@ -378,6 +380,7 @@ public final class ReaGhidraBridge extends HeadlessScript { return switch (request.method) { case "inspect_native_load_image" -> inspectNativeLoadImage(request.params); case "read_bytes" -> readMemoryBytes(request.params); + case "search_bytes" -> searchMemoryBytes(request.params, descriptor); case "address_to_file_offset" -> addressToFileOffset(request.params); case "address_name" -> addressName(request.params); case "list_documents" -> listDocuments(request.params); @@ -684,6 +687,160 @@ public final class ReaGhidraBridge extends HeadlessScript { return result; } + private JsonObject searchMemoryBytes(JsonObject params, SessionDescriptor descriptor) throws Exception { + // mask, start, end, and cursor are omitted when absent; JSON encoding drops those keys. + Set keys = params.keySet(); + if (!keys.containsAll(Set.of("document", "pattern", "limit")) || + !Set.of("document", "pattern", "mask", "start", "end", "limit", "cursor").containsAll(keys)) + throw new RequestFailure("invalid_request", "Bridge request fields are invalid"); + requireDocument(params); + // Public input validation owns the grammar; validate again at the socket boundary. + String pattern = requireString(params, "pattern").replaceAll("[\\s\\p{Z}\\uFEFF]", ""); + String mask = params.has("mask") ? requireString(params, "mask") : null; + if (mask != null) mask = mask.replaceAll("[\\s\\p{Z}\\uFEFF]", ""); + if (pattern.isEmpty() || pattern.length() % 2 != 0 || + (mask != null && mask.length() != pattern.length())) + throw new RequestFailure("invalid_request", "Mask length must equal a nonempty byte pattern length"); + byte[] bytes = new byte[pattern.length() / 2]; + byte[] masks = new byte[bytes.length]; + boolean constrained = false; + for (int index = 0; index < bytes.length; index++) { + monitor.checkCancelled(); + String pair = pattern.substring(index * 2, index * 2 + 2); + if ((!pair.equals("??") && !isHexadecimal(pair, 0, 2)) || + (mask != null && !isHexadecimal(mask, index * 2, index * 2 + 2))) + throw new RequestFailure("invalid_request", "Use hexadecimal byte pairs or ?? wildcards"); + int selected = mask == null ? 255 : Integer.parseInt(mask.substring(index * 2, index * 2 + 2), 16); + bytes[index] = (byte) (pair.equals("??") ? 0 : Integer.parseInt(pair, 16)); + masks[index] = (byte) (pair.equals("??") ? 0 : selected); + constrained |= masks[index] != 0; + } + if (!constrained) throw new RequestFailure("invalid_request", "Pattern must constrain at least one bit"); + int limit = requireBoundedInteger(params, "limit", 1, 1000); + Address start = params.has("start") ? requireAddress(params, "start") : null; + Address end = params.has("end") ? requireAddress(params, "end") : null; + if (start != null && end != null && + (!start.getAddressSpace().equals(end.getAddressSpace()) || start.compareTo(end) > 0)) + throw new RequestFailure("invalid_request", "Search bounds must be ordered in one address space"); + JsonArray identity = new JsonArray(); + for (String value : new String[] { descriptor.targetSha256, descriptor.profileDigest, + bytesHex(bytes), bytesHex(masks), start == null ? "" : canonicalAddress(start), + end == null ? "" : canonicalAddress(end), optionalString(params, "document") }) identity.add(value); + String binding = bytesHex(MessageDigest.getInstance("SHA-256").digest(identity.toString().getBytes(StandardCharsets.UTF_8))); + String cursor = params.has("cursor") ? requireString(params, "cursor") : null; + Address resume = null; + if (cursor != null) { + if (!cursor.startsWith(binding + ":")) + throw new RequestFailure("invalid_request", "Byte search cursor does not match the target and query"); + resume = parseReaAddress(cursor.substring(binding.length() + 1)); + MemoryBlock resumedBlock = currentProgram.getMemory().getBlock(resume); + Address bound = start == null ? end : start; + if (resumedBlock == null || !resumedBlock.isInitialized() || + (bound != null && !bound.getAddressSpace().equals(resume.getAddressSpace())) || + (start != null && resume.compareTo(start) < 0) || + (end != null && resume.compareTo(end) > 0)) + throw new RequestFailure("invalid_request", "Byte search cursor lies outside initialized query memory"); + } + List ordered = new ArrayList<>(List.of(currentProgram.getMemory().getBlocks())); + ordered.sort(Comparator.comparing(MemoryBlock::getStart)); + JsonArray matches = new JsonArray(); + JsonArray blocks = new JsonArray(); + JsonArray skipped = new JsonArray(); + JsonArray notLoaded = new JsonArray(); + long covered = 0; + String next = null; + for (MemoryBlock block : ordered) { + monitor.checkCancelled(); + Address low = block.getStart(); + Address high = block.getEnd(); + Address bound = start == null ? end : start; + if (bound != null && !bound.getAddressSpace().equals(low.getAddressSpace())) continue; + // Without bounds, search loaded memory only. Non-loaded file parts + // (such as .symtab) and overlays stay reachable through explicit bounds. + if (bound == null && (!block.isLoaded() || block.isOverlay())) { + notLoaded.add(byteSearchCoverage(block, low, high)); + continue; + } + if (start != null && low.compareTo(start) < 0) low = start; + if (end != null && high.compareTo(end) > 0) high = end; + if (low.compareTo(high) > 0) continue; + if (!block.isInitialized()) { + skipped.add(byteSearchCoverage(block, low, high)); + continue; + } + if (next != null) continue; + if (resume != null && low.compareTo(resume) < 0) low = resume; + if (low.compareTo(high) > 0 || !low.getAddressSpace().equals(high.getAddressSpace())) continue; + Address at = low; + Address examinedEnd = high; + while (at != null && at.compareTo(high) <= 0 && high.subtract(at) >= bytes.length - 1L) { + monitor.checkCancelled(); + // Ghidra's end address bounds candidate starts, not the final matched byte. + Address found = currentProgram.getMemory().findBytes(at, high.subtract(bytes.length - 1L), bytes, masks, true, monitor); + monitor.checkCancelled(); + if (found == null || high.subtract(found) < bytes.length - 1L) break; + if (matches.size() == limit) { + next = binding + ":" + canonicalAddress(found); + examinedEnd = found.addNoWrap(bytes.length - 1L); + break; + } + JsonObject match = new JsonObject(); + match.addProperty("address", canonicalAddress(found)); + match.addProperty("segment", block.getName()); + Long offset = targetFileOffset(block, found).offset(); + match.add("file_offset", offset == null ? JsonNull.INSTANCE : GSON.toJsonTree(offset)); + Function function = currentProgram.getFunctionManager().getFunctionContaining(found); + JsonObject procedure = new JsonObject(); + if (function != null) { + procedure.addProperty("address", canonicalAddress(function.getEntryPoint())); + procedure.addProperty("name", function.getName(true)); + } + match.add("procedure", function == null ? JsonNull.INSTANCE : procedure); + matches.add(match); + at = found.next(); + } + JsonObject coverage = byteSearchCoverage(block, low, examinedEnd); + covered = Math.addExact(covered, coverage.get("bytes").getAsLong()); + blocks.add(coverage); + } + JsonObject searched = new JsonObject(); + searched.add("blocks", blocks); + searched.addProperty("bytes", covered); + searched.add("skipped_uninitialized", skipped); + searched.add("skipped_not_loaded", notLoaded); + JsonObject result = new JsonObject(); + result.add("matches", matches); + result.add("searched", searched); + result.addProperty("complete", next == null); + result.add("next_cursor", next == null ? JsonNull.INSTANCE : GSON.toJsonTree(next)); + return result; + } + + private static JsonObject byteSearchCoverage(MemoryBlock block, Address low, Address high) { + JsonObject result = new JsonObject(); + result.addProperty("segment", block.getName()); + result.addProperty("start", canonicalAddress(low)); + result.addProperty("end", canonicalAddress(high)); + result.addProperty("bytes", high.subtract(low) + 1); + return result; + } + + private record TargetFileOffset(Long offset, int mappings) {} + + private TargetFileOffset targetFileOffset(MemoryBlock block, Address address) throws Exception { + Long offset = null; + int mappings = 0; + for (MemoryBlockSourceInfo source : block.getSourceInfos()) { + if (!source.contains(address) || source.getFileBytes().isEmpty() || + !isTargetSource(source.getFileBytes().get())) continue; + long candidate = source.getFileBytesOffset(address); + if (candidate < 0) continue; + offset = candidate; + mappings++; + } + return new TargetFileOffset(mappings == 1 && offset <= 9007199254740991L ? offset : null, mappings); + } + private JsonObject addressToFileOffset(JsonObject params) throws Exception { requireKeys(params, Set.of("document", "address")); requireDocument(params); @@ -691,18 +848,12 @@ public final class ReaGhidraBridge extends HeadlessScript { MemoryBlock block = currentProgram.getMemory().getBlock(address); if (block == null || !block.isInitialized()) throw new RequestFailure("invalid_request", "Address has no initialized file-backed memory: " + canonicalAddress(address)); - Long fileOffset = null; - int mappings = 0; - for (MemoryBlockSourceInfo source : block.getSourceInfos()) { - if (!source.contains(address) || source.getFileBytes().isEmpty() || - !isTargetSource(source.getFileBytes().get())) continue; - long offset = source.getFileBytesOffset(address); - if (offset < 0) continue; - fileOffset = offset; - mappings++; - } - if (mappings != 1) - throw new RequestFailure("invalid_request", "Address has " + mappings + " authoritative target-file mappings: " + canonicalAddress(address)); + TargetFileOffset mapping = targetFileOffset(block, address); + if (mapping.mappings() != 1) + throw new RequestFailure("invalid_request", "Address has " + mapping.mappings() + " authoritative target-file mappings: " + canonicalAddress(address)); + Long fileOffset = mapping.offset(); + if (fileOffset == null) + throw new RequestFailure("invalid_request", "Address lacks a safe file offset: " + canonicalAddress(address)); JsonObject result = new JsonObject(); result.addProperty("address", canonicalAddress(address)); result.addProperty("file_offset", fileOffset); diff --git a/docs/client-configuration.md b/docs/client-configuration.md index 1a85dbdfa..409bc7f19 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -129,6 +129,18 @@ and Claude Code reports that autocompact is thrashing. To avoid this: The sizes above come from REA's own `tools/list`. A live Claude Code session against DeepSeek's API is unverified. +## Kimi Code + +Setup registers `rea` in `~/.kimi-code/mcp.json`, or +`$KIMI_CODE_HOME/mcp.json` when set, while preserving unrelated settings and +creating a `.rea.backup` before updating an existing file. Kimi Code infers +stdio from the `command` entry, so setup does not add a `type` field. The +registration includes `startupTimeoutMs = 30000` and `toolTimeoutMs = 120000`. +Kimi Code also discovers personal skills in `~/.agents/skills` and project-local +`.agents/skills`, so no separate skill root is required; setup still installs +and verifies the matching skill files through the normal shared-skill flow. +Start a new Kimi Code session to pick up MCP and skill changes. + ## Pi For [Pi](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/mcp.md), diff --git a/docs/client-verification.md b/docs/client-verification.md index d9ece9aad..38d0bf399 100644 --- a/docs/client-verification.md +++ b/docs/client-verification.md @@ -458,7 +458,7 @@ artifact path. Its `expectedFirstTool` is `null`, and `targetClarificationPassed` grades that limited routing outcome independently of analysis prose heuristics. Analyzing a nearby example cannot pass this case. -The packaged desktop and parser-comparison scenarios now assess a closed set of +The packaged desktop, parser-comparison, and managed scenarios assess a closed set of known fixture claims. Each final answer must be a strict JSON manifest containing exactly the requested claim IDs, values, Evidence IDs, and Evidence authority and confidence. Golden values are not included in the prompt. The evaluator checks @@ -475,15 +475,24 @@ analyses and use the requested module and export selectors. Boundary tests packa the same source-owned desktop fixture and compare the same source-owned parser files through actual MCP tool results, then verify that fabricated answers fail. +The managed rubric checks the exact public top-level type set and the CLI entry +point's declaring type and method name. The exact type-set claim requires an +unfiltered member inventory: `selection` must be null and the Evidence parameters +must omit `type_names`. A selected inventory can still ground the entry-point +claim. Both inspections must describe the freshly built DLL's SHA-256. Entry-point +resolution additionally requires complete artifact and member metadata, matching +non-null module MVIDs, and a unique method at the header's metadata token. These are +static metadata claims, not runtime behavior; +compiler-generated member counts, token values, and MVIDs are not golden answers. + Per-scenario `factualCorrectness` is `passed`, `failed`, or `not_assessed` within the `configured_fixture_claims` scope. A pass establishes only the configured -claims, not unrestricted factual correctness. Native, managed, browser, +claims, not unrestricted factual correctness. Native, browser, navigation-context, and address-context scenarios have no factual rubric and -remain `not_assessed`; the managed workflow requires both artifact and member -inspection to answer its type and entry-point question. +remain `not_assessed`. The closed factual scenarios request complete producer Evidence on the initial -analysis so their source selectors can authenticate all configured facts. +inspection or analysis so their source selectors can authenticate all configured facts. Summary/page/item workflows are exercised separately by `javascript-module-view`; its heuristic gate does not establish full-producer factual correctness. diff --git a/docs/ghidra-provider.md b/docs/ghidra-provider.md index 0876b8ad5..fd23dcd07 100644 --- a/docs/ghidra-provider.md +++ b/docs/ghidra-provider.md @@ -198,3 +198,32 @@ It accepts the same architecture and DLL flags and verifies the selected PE role in the analysis profile. All lanes require matching native controls; package startup alone establishes only package compatibility. + +## Masked byte search + +`search_bytes` (`rea search-bytes PATH '48 8B ?? ?? 89' --provider ghidra`) +searches initialized analyzed memory, including loader fixups. Supply hexadecimal +byte pairs, with `??` for any byte. An optional equal-length hexadecimal `mask` +selects bits: `pattern: "a0"`, `mask: "f0"` matches bytes from `a0` through `af`. +Wildcards remain unconstrained even with an explicit mask. At least one bit +must be constrained. + +Optional `start` and `end` addresses are inclusive and select one address space. +A match must end at or before `end`, so `end` set inside a match excludes it. +Without bounds, loaded memory blocks are eligible, in Ghidra address order. +Blocks that are not loaded (file parts such as `.symtab`) and overlay blocks are +listed in `searched.skipped_not_loaded` and are not searched; pass `start` or +`end` in their address space to search them. +Overlapping matches are included; matches never cross block boundaries. +Each match reports its address, segment, unique source file offset (or null), +and containing procedure (or null). + +Pages default to 100 matches and accept 1 to 1000. Pass `next_cursor` as `cursor` +with the same target, profile, pattern, mask and bounds to continue. `complete` +means no further matches remain. `searched.blocks` and `searched.bytes` describe +the inclusive memory ranges examined for this page, including lookahead for the +next match. Coverage can overlap between pages; do not sum it as unique memory. +`searched.skipped_uninitialized` lists eligible uninitialized blocks on every +page. These blocks are never searched. Cancellation returns a typed error, +discards the active Ghidra database, and does not return a successful partial page. +Hopper byte search remains unavailable until its implementation is verified. diff --git a/docs/installation.md b/docs/installation.md index bb0adfbc9..a39471585 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -279,6 +279,7 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s | GitHub Copilot CLI | `copilot_cli` | | Command Code | `commandcode` | | Qwen Code | `qwen_code` | +| Kimi Code | `kimi` | | VS Code | `vscode` | | Grok Build | `grok_build` | | OMP | `omp` | @@ -290,8 +291,8 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s | ZCode | `zcode` | | DeepSeek Harness | `dsh` | -`dsh` is main-only until a release after 6.4.0. The registry check above does -not cover it. +`dsh` and `kimi` 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. @@ -310,9 +311,12 @@ Hopper is separate commercial software with its own license. Its free demo has vendor-defined limits, and a paid license is optional. REA reuses any detected installation and preserves Hopper during uninstall. -The supported native host baseline is macOS 12+, Ubuntu 24.04+, Fedora 41+, -Nobara 44+, 64-bit Arch Linux, CachyOS, or Omarchy. Ghidra and IDA have their own provider-specific -host requirements; Windows Ghidra uses the [experimental P0 boundary](windows-ghidra-p0.md). +The automated Hopper installer supports macOS 12+, Ubuntu 24.04+, Fedora 41+, +Nobara 44+, 64-bit Arch Linux, CachyOS, or Omarchy. REA itself runs on any +Linux distribution with a recognized apt, dnf, or pacman package family, where +an existing Hopper launcher is selected through `HOPPER_LAUNCHER_PATH`. Ghidra +and IDA have their own provider-specific host requirements; Windows Ghidra uses +the [experimental P0 boundary](windows-ghidra-p0.md). On macOS, approved setup downloads the official DMG, checks its published size and digest, validates the application bundle, and atomically installs it to diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 7c219f6be..63ab516d5 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -65,7 +65,7 @@ unresolved relationship reads. Cancellation remains a tool error. REA prepares complete MCP results within the pinned stdio client's 10 MiB receive-buffer budget, including both text and structured representations and room for the JSON-RPC envelope. Tool errors carry only their text -representation, so only that text counts against the budget. Response budget settings are captured at startup; +representation and `isError` flag, so only those count against the budget. Response budget settings are captured at startup; restart or recreate the server to apply changes. If a result cannot fit, REA returns `resource_constraint` with `details.resource: "transport"` before constructing a document-sized string. Analysis Evidence remains complete in the current diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 5a2fcbd31..f963fd4d8 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -549,3 +549,17 @@ Value projection preserves serialized node kinds, IDs, references and pagination Exact and ambiguous value counts describe emitted root/object value instances; a root and an object may contain the same value. Archive digests identify the original bytes. + +## Locate a byte signature + +Use `search_bytes` after opening a target with Ghidra to locate a known constant +or instruction sequence. For example, `{"pattern":"48 8B ?? ?? 89","limit":100}` +returns ordered addresses with segments, source file offsets and containing +procedures. The CLI equivalent is +`rea search-bytes ./program '48 8B ?? ?? 89' --provider ghidra`. +Use `start`/`end` to constrain the inclusive address window, and continue with +`next_cursor` until `complete` is true. Inspect `searched` for page coverage and +skipped uninitialized blocks. A missing match does not establish absence from +uninitialized memory or across block boundaries. See +[Ghidra byte search](ghidra-provider.md#masked-byte-search) for mask and coverage +semantics. This query never executes or modifies target bytes. diff --git a/docs/user-visible-errors.csv b/docs/user-visible-errors.csv index a9dd0d05d..02237dcdc 100644 --- a/docs/user-visible-errors.csv +++ b/docs/user-visible-errors.csv @@ -1,4 +1,7 @@ harm_rank,location,trigger,current_copy,user_risk,proposed_replacement,implementation_status,verification_result +5,src/domain/native/bytePattern.ts,Unconstrained byte signature,Pattern must constrain at least one bit,Low risk; identifies input correction,Keep current copy,implemented,parser and MCP validation tests +5,src/domain/native/bytePattern.ts,Unequal mask length,Mask length must equal pattern length,Low risk; identifies input correction,Keep current copy,implemented,parser and MCP validation tests +5,bridge/ghidra/ReaGhidraBridge.java:searchMemoryBytes,Cursor reused with another query,Byte search cursor does not match the target and query,Low risk; identifies continuation mismatch,Keep current copy,implemented,real-provider verification required 1,src/domain/errors.ts:AnalysisCapabilityUnavailableError,Requested analysis unavailable,"Provider {providerId} cannot execute {operation}: {reason}",Exposes provider and internal operation/reason,"This analysis is unavailable for the current target. Choose another analysis or target.",implemented,focused assertions passed; full `npm run check` passed 2,src/domain/errors.ts:ProviderAdapterError,Provider adapter fails,"Provider {providerId} adapter failed during {operation}",Exposes provider and implementation boundary,"Analysis could not complete. Run `rea doctor` and review the reported details; repeating the unchanged request is not expected to succeed.",implemented,focused assertions passed; full `npm run check` passed 3,src/domain/errors.ts:HopperRemoteError,Remote analysis request fails,"Hopper request failed ({code}): {safeMessage}",Exposes provider name code and upstream text,"Analysis could not complete. Run `rea doctor` and review the reported details; repeating the unchanged request is not expected to succeed.",implemented,focused assertions passed; full `npm run check` passed diff --git a/scripts/verify-agent-experience.mjs b/scripts/verify-agent-experience.mjs index 99ab060dd..71cd6383c 100644 --- a/scripts/verify-agent-experience.mjs +++ b/scripts/verify-agent-experience.mjs @@ -1,4 +1,5 @@ import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; import { chmod, copyFile, @@ -259,7 +260,7 @@ try { const execution = await runCodex( answerInstructions.length === 0 ? scenario.prompt - : `${scenario.prompt}\n\nThis closed factual rubric needs complete producer Evidence in the transcript. Request complete detail on each initial application analysis when supported; summary or view records alone are not accepted by this rubric.\n\n${answerInstructions}`, + : `${scenario.prompt}\n\nThis closed factual rubric needs complete producer Evidence in the transcript. Request complete detail on each initial inspection or analysis when supported; summary or view records alone are not accepted by this rubric.\n\n${answerInstructions}`, scenario.approvedTools ?? [], ); const transcriptDirectory = process.env.REA_AGENT_EVAL_TRANSCRIPT_DIR; @@ -440,6 +441,13 @@ async function createTargets(root, includeManaged) { ...fixtures, largeJavascript, managed: join(managedOutput, "AgentEval.dll"), + ...(includeManaged + ? { + managedSha256: createHash("sha256") + .update(await readFile(join(managedOutput, "AgentEval.dll"))) + .digest("hex"), + } + : {}), }; } diff --git a/scripts/verify-real-ghidra-raw.mjs b/scripts/verify-real-ghidra-raw.mjs index befba80e0..4341b5cfd 100644 --- a/scripts/verify-real-ghidra-raw.mjs +++ b/scripts/verify-real-ghidra-raw.mjs @@ -114,6 +114,22 @@ try { length: bytes.length, }); assert.equal(memory.normalized_result.bytes_hex, bytes.toString("hex")); + const signature = await call("search_bytes", { + pattern: "1e ?? 2f e1", + limit: 1000, + }); + assert.equal(signature.normalized_result.complete, true); + assert.equal(signature.normalized_result.next_cursor, null); + assert.equal(signature.normalized_result.searched.bytes, bytes.length); + assert.deepEqual( + signature.normalized_result.matches.map((match) => match.address), + [entry], + ); + assert.equal(signature.normalized_result.matches[0].file_offset, 0); + assert.equal( + signature.normalized_result.matches[0].procedure.address, + entry, + ); const procedures = await call("list_procedures"); assert.ok( procedures.normalized_result.some( @@ -211,6 +227,69 @@ try { return_42: true, }); } + const repeatedPath = join(workspace, "signatures.bin"); + const repeatedBytes = Buffer.from("b82a000000c3".repeat(1005), "hex"); + const repeatedSha256 = createHash("sha256") + .update(repeatedBytes) + .digest("hex"); + await writeFile(repeatedPath, repeatedBytes); + await call("open_binary", { + path: repeatedPath, + format: "raw-binary", + architecture: "x86_64", + entry_point: "0x1000", + }); + // Import/analysis is complete before timing the first uncached search. + await call("read_bytes", { address: "0x1000", length: 6 }, repeatedSha256); + const started = performance.now(); + const page = await call( + "search_bytes", + { pattern: "b8 ?? 00 00 00 c3", limit: 1000 }, + repeatedSha256, + ); + const searchElapsedMs = performance.now() - started; + assert.equal(page.normalized_result.matches.length, 1000); + assert.equal(page.normalized_result.complete, false); + const rest = await call( + "search_bytes", + { + pattern: "b8 ?? 00 00 00 c3", + limit: 1000, + cursor: page.normalized_result.next_cursor, + }, + repeatedSha256, + ); + assert.equal(rest.normalized_result.matches.length, 5); + assert.equal( + rest.normalized_result.matches[0].address, + `0x${(0x1000 + 6000).toString(16)}`, + ); + assert.equal(rest.normalized_result.complete, true); + const masked = await call( + "search_bytes", + { pattern: "bf", mask: "f0", start: "0x1000", end: "0x1005" }, + repeatedSha256, + ); + assert.deepEqual( + masked.normalized_result.matches.map((match) => match.address), + ["0x1000"], + ); + const overlapping = await call( + "search_bytes", + { pattern: "00 00", start: "0x1000", end: "0x1005" }, + repeatedSha256, + ); + assert.deepEqual( + overlapping.normalized_result.matches.map((match) => match.address), + ["0x1002", "0x1003"], + ); + const wrongCursor = await client.callTool({ + name: "search_bytes", + arguments: { pattern: "c3", cursor: page.normalized_result.next_cursor }, + }); + assert.equal(wrongCursor.isError, true); + await call("close_binary"); + assert.deepEqual(await readFile(repeatedPath), repeatedBytes); report = { ok: true, mocked: false, @@ -231,6 +310,15 @@ try { load_images_verified: true, original_unchanged: true, x86_targets: x86Targets, + byte_search: { + matches: 1005, + page_size: 1000, + first_search_elapsed_ms: searchElapsedMs, + timing_scope: "MCP request after import; first uncached search", + token_usage: "unmeasured", + process_family_cpu_time: "unmeasured", + peak_process_family_memory: "unmeasured", + }, }; } catch (error) { if (stderr.trim()) process.stderr.write(stderr); diff --git a/src/application/AppBundleExecutable.test.ts b/src/application/AppBundleExecutable.test.ts new file mode 100644 index 000000000..942a00112 --- /dev/null +++ b/src/application/AppBundleExecutable.test.ts @@ -0,0 +1,34 @@ +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { resolveAppBundleExecutable } from "./AppBundleExecutable.js"; + +describe("app bundle Info.plist magic", () => { + it("does not decode a high-bit fold of bplist as a binary Info.plist", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-bplist-fold-")); + try { + const bundle = join(root, "Fixture.app"); + const plistPath = join(bundle, "Contents", "Info.plist"); + await mkdir(join(bundle, "Contents"), { recursive: true }); + const plist = Buffer.from("bplist00"); + plist[0] = (plist[0] ?? 0) | 0x80; + await writeFile(plistPath, plist); + let decoded = false; + const result = await resolveAppBundleExecutable(bundle, { + readFile: async () => plist, + realpath: async (path) => path, + decodeBinaryPlist: async () => { + decoded = true; + return "Fixture"; + }, + }); + expect(decoded).toBe(false); + expect(result.ok).toBe(false); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/src/application/ClientConfigurationDocument.ts b/src/application/ClientConfigurationDocument.ts index 1b241a98b..a226f8cb6 100644 --- a/src/application/ClientConfigurationDocument.ts +++ b/src/application/ClientConfigurationDocument.ts @@ -110,9 +110,12 @@ const parseDocument = ( return requireObject(parseToml(text), "The configuration root"); // Pi loads `mcp.json` with a strict `JSON.parse` and no BOM tolerance, so // comments, trailing commas, a BOM, or an empty existing file must be - // rejected instead of registering into a document Pi cannot load. - if (format === "pi") + // rejected instead of registering into a document the client cannot load. + // Kimi also reads mcp.json with JSON.parse and an empty-file fallback. + if (format === "pi" || format === "kimi") { + if (format === "kimi" && text.trim().length === 0) return {}; return requireObject(JSON.parse(text), "The configuration root"); + } // An empty file, which some clients create before any server is added, // holds no settings to preserve. if (isEmptyClientConfigurationText(text)) return {}; @@ -1446,6 +1449,13 @@ export const clientRegistrationEntry = ( ? {} : { environment: { ...environment } }), }; + case "kimi": + // Kimi infers stdio from command; it uses `transport`, not `type`. + return { + command: executable, + args, + ...(Object.keys(environment).length === 0 ? {} : { env: environment }), + }; case "opencode_v2": // V2 connects servers unless `disabled` is true. return { diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts index d107c5f32..56a3e81ba 100644 --- a/src/application/ClientRegistrationStatus.ts +++ b/src/application/ClientRegistrationStatus.ts @@ -66,6 +66,8 @@ const registrationSchema = z command: z.string().min(1), args: z.array(z.string()).default([]), startup_timeout_sec: z.number().positive().optional(), + startupTimeoutMs: z.number().positive().optional(), + toolTimeoutMs: z.number().positive().optional(), type: z.string().optional(), transport: z.string().optional(), serverName: z.string().optional(), @@ -258,6 +260,21 @@ const registrationAligned = ( MCP_STARTUP_POLICY.codexStartupTimeoutSeconds ) return false; + if ( + client.name === "kimi" && + (registration.startupTimeoutMs !== + MCP_STARTUP_POLICY.kimiStartupTimeoutMs || + registration.toolTimeoutMs !== MCP_STARTUP_POLICY.kimiToolTimeoutMs) + ) + return false; + if ( + (client.format === "kimi" || + client.format === "omp" || + client.format === "pi") && + registration.type !== undefined && + registration.type !== "stdio" + ) + return false; if (client.format === "vscode" && registration.type !== "stdio") return false; // A Harness registration only connects as the rea stdio server; another // server name or transport leaves the session without REA tools. @@ -266,13 +283,6 @@ const registrationAligned = ( (registration.serverName !== "rea" || registration.transport !== "stdio") ) return false; - // OMP and Pi infer stdio for a command entry without an explicit type. - if ( - (client.format === "omp" || client.format === "pi") && - registration.type !== undefined && - registration.type !== "stdio" - ) - return false; if ( client.format === "copilot_cli" && (registration.type !== "stdio" || @@ -331,6 +341,12 @@ const parseRegistration = ( }); } const registration = registrationSchema.parse(value); + if ( + client.format === "kimi" && + registration.transport !== undefined && + registration.transport !== "stdio" + ) + throw new TypeError("Expected an stdio registration"); if ( (client.format === "vscode" || client.format === "copilot_cli") && registration.type !== "stdio" @@ -339,7 +355,9 @@ const parseRegistration = ( if (client.format === "commandcode" && registration.transport !== "stdio") throw new TypeError("Expected an stdio registration"); if ( - (client.format === "omp" || client.format === "pi") && + (client.format === "kimi" || + client.format === "omp" || + client.format === "pi") && registration.type !== undefined && registration.type !== "stdio" ) diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index 0bdb69864..8a7874598 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -60,6 +60,7 @@ type DirectAnalysisTool = | "annotate_native_function" | "inspect_native_load_image" | "read_bytes" + | "search_bytes" | "address_to_file_offset" | "binary_overview" | "procedure_pseudo_code" diff --git a/src/application/Doctor.test.ts b/src/application/Doctor.test.ts index 4251bbaf2..a39f81e35 100644 --- a/src/application/Doctor.test.ts +++ b/src/application/Doctor.test.ts @@ -74,7 +74,7 @@ describe("doctor", () => { ), ).toEqual({ node: `Install ${SUPPORTED_NODE_VERSION_PROSE}.`, - host: "REA supports macOS 12+, Ubuntu 24.04+, Fedora 41+, Nobara 44+, 64-bit Arch Linux, CachyOS, Omarchy, and the experimental Windows x64 Ghidra P0 boundary.", + host: "REA supports macOS 12+, Linux distributions with a recognized apt, dnf, or pacman package family, and the experimental Windows x64 Ghidra P0 boundary; automated Hopper setup additionally requires Ubuntu 24.04+, Fedora 41+, Nobara 44+, 64-bit Arch Linux, CachyOS, or Omarchy.", hopper: "Run rea setup to install Hopper, or set HOPPER_LAUNCHER_PATH.", target: "Supply a readable local app or program path.", }); @@ -418,10 +418,9 @@ describe("doctor scoped readiness", () => { platform: "linux", linuxDistribution: () => Promise.resolve({ - id: "debian", - versionId: "13", - packageFamily: "deb", - supported: false, + id: "nixos", + packageFamily: undefined, + hopperSetupSupported: false, }), clientRegistrations: () => Promise.resolve([ @@ -451,6 +450,31 @@ describe("doctor scoped readiness", () => { ); }); + it("accepts a Debian-family host that has no tested Hopper package", async () => { + const result = await runDoctor( + undefined, + host({ + platform: "linux", + macosVersion: () => Promise.resolve(undefined), + linuxDistribution: () => + Promise.resolve({ + id: "kali", + versionId: "2026.3", + packageFamily: "deb", + hopperSetupSupported: false, + }), + }), + ); + + expect(result.checks).toContainEqual( + expect.objectContaining({ + name: "host", + ok: true, + detail: "kali 2026.3", + }), + ); + }); + it("requires explicitly selected providers instead of any available provider", async () => { const ghidra = { id: "ghidra", @@ -502,7 +526,7 @@ describe("doctor Linux and Hopper discovery", () => { id: "ubuntu", versionId: "24.04", packageFamily: "deb", - supported: true, + hopperSetupSupported: true, }), configuredHopperPath: path, executable: (candidate) => Promise.resolve(candidate === path), @@ -522,7 +546,7 @@ describe("doctor Linux and Hopper discovery", () => { id: "ubuntu", versionId: "24.04", packageFamily: "deb", - supported: true, + hopperSetupSupported: true, }), linuxDemoRuntimeCheck: () => Promise.resolve({ @@ -554,7 +578,7 @@ describe("doctor Linux and Hopper discovery", () => { id: "ubuntu", versionId: "24.04", packageFamily: "deb", - supported: true, + hopperSetupSupported: true, }), executable: (candidate) => Promise.resolve(candidate === path), supportedLinuxHopper: () => Promise.resolve(false), diff --git a/src/application/DoctorDiagnostics.ts b/src/application/DoctorDiagnostics.ts index 354d08636..1d8a5e621 100644 --- a/src/application/DoctorDiagnostics.ts +++ b/src/application/DoctorDiagnostics.ts @@ -2,7 +2,7 @@ import { SUPPORTED_NODE_VERSION_PROSE, supportsNodeVersion, } from "../domain/runtimeVersion.js"; -import { linuxHopperLauncherPath } from "./LinuxHopper.js"; +import { linuxHopperLauncherPath, supportsReaHost } from "./LinuxHopper.js"; import type { DoctorCheck, DoctorHost, @@ -129,7 +129,7 @@ const hostCheck = async (host: DoctorHost): Promise => { host.platform === "linux" ? await host.linuxDistribution() : undefined; const supported = (host.platform === "darwin" && parseMajor(macosVersion) >= 12) || - (host.platform === "linux" && linuxDistribution?.supported === true) || + (host.platform === "linux" && supportsReaHost(linuxDistribution)) || (host.platform === "win32" && host.architecture === "x64"); const detail = macosVersion ?? @@ -138,7 +138,7 @@ const hostCheck = async (host: DoctorHost): Promise => { : `${linuxDistribution.id} ${linuxDistribution.versionId ?? "unknown"}`); return check("host", supported, detail, { remediation: - "REA supports macOS 12+, Ubuntu 24.04+, Fedora 41+, Nobara 44+, 64-bit Arch Linux, CachyOS, Omarchy, and the experimental Windows x64 Ghidra P0 boundary.", + "REA supports macOS 12+, Linux distributions with a recognized apt, dnf, or pacman package family, and the experimental Windows x64 Ghidra P0 boundary; automated Hopper setup additionally requires Ubuntu 24.04+, Fedora 41+, Nobara 44+, 64-bit Arch Linux, CachyOS, or Omarchy.", classification: "unsupported_host", }); }; diff --git a/src/application/LinuxHopper.test.ts b/src/application/LinuxHopper.test.ts index b5cf2d38f..1c37a03ea 100644 --- a/src/application/LinuxHopper.test.ts +++ b/src/application/LinuxHopper.test.ts @@ -7,6 +7,7 @@ import { linuxPackageManagerCommands, linuxSharedLibrariesAvailable, parseLinuxDistribution, + supportsReaHost, type LinuxDistribution, type LinuxHopperDownload, type LinuxHopperInstallHost, @@ -19,7 +20,7 @@ class RecordingLinuxHost implements LinuxHopperInstallHost { id: "ubuntu", versionId: "24.04", packageFamily: "deb", - supported: true, + hopperSetupSupported: true, }; archive = new Uint8Array([1, 2, 3]); archiveOk = true; @@ -66,7 +67,7 @@ describe("Linux Hopper host classification", () => { (document, family) => { expect(parseLinuxDistribution(document)).toMatchObject({ packageFamily: family, - supported: true, + hopperSetupSupported: true, }); }, ); @@ -99,8 +100,32 @@ describe("Linux Hopper host classification", () => { "ID=garuda\nID_LIKE=arch\n", "ID=omarchy\n", "ID=omarchy\nID_LIKE=debian\n", - ])("rejects unsupported vendor/version combinations", (document) => { - expect(parseLinuxDistribution(document).supported).toBe(false); + ])( + "rejects vendor/version combinations without a tested Hopper installer", + (document) => { + expect(parseLinuxDistribution(document).hopperSetupSupported).toBe(false); + }, + ); + + it.each([ + 'ID=kali\nVERSION_ID="2026.3"\nID_LIKE=debian\n', + 'ID=debian\nVERSION_ID="13"\nID_LIKE=debian\n', + 'ID=linuxmint\nVERSION_ID="22.1"\nID_LIKE="ubuntu debian"\n', + "ID=manjaro\nID_LIKE=arch\n", + "ID=derivative\nID_LIKE='debian'\n", + "ID='debian'\nVERSION_ID='13'\n", + ])( + "hosts REA on a distribution Hopper has no tested package for: %s", + (document) => { + const distribution = parseLinuxDistribution(document); + expect(distribution.hopperSetupSupported).toBe(false); + expect(supportsReaHost(distribution)).toBe(true); + }, + ); + + it("withholds host support from a distribution with no package family", () => { + expect(supportsReaHost(parseLinuxDistribution("ID=nixos\n"))).toBe(false); + expect(supportsReaHost(undefined)).toBe(false); }); }); @@ -166,7 +191,7 @@ describe("Linux Hopper installation", () => { host.distributionValue = { id: family, packageFamily: family, - supported: true, + hopperSetupSupported: true, }; const result = await installFixture(host); expect(result).toEqual({ @@ -194,7 +219,7 @@ describe("Linux Hopper installation", () => { host.distributionValue = { id: "debian", packageFamily: "deb", - supported: false, + hopperSetupSupported: false, }; if (scenario === "package request failure") host.archiveOk = false; if (scenario === "checksum mismatch") host.archive = new Uint8Array([9]); diff --git a/src/application/LinuxHopper.ts b/src/application/LinuxHopper.ts index 3a46106bb..765f31d29 100644 --- a/src/application/LinuxHopper.ts +++ b/src/application/LinuxHopper.ts @@ -76,19 +76,35 @@ interface LinuxDistributionIdentity { readonly versionId?: string; } -/** Parsed Linux distribution information used for support and package selection. */ +/** + * Parsed Linux distribution information used for package selection. + * + * `hopperSetupSupported` is the automated Hopper installer's tested list. It is + * narrower than the hosts where REA itself runs, which {@link supportsReaHost} + * answers from the package family. + */ export type LinuxDistribution = LinuxDistributionIdentity & ( | { readonly packageFamily: LinuxPackageFamily; - readonly supported: true; + readonly hopperSetupSupported: true; } | { readonly packageFamily: LinuxPackageFamily | undefined; - readonly supported: false; + readonly hopperSetupSupported: false; } ); +/** + * Whether REA's analysis providers run on this distribution. A recognized + * package family means a glibc host with a known package layout, which is what + * the providers need; downloading and installing the official Hopper package + * additionally requires a distribution on its tested list. + */ +export const supportsReaHost = ( + distribution: LinuxDistribution | undefined, +): boolean => distribution?.packageFamily !== undefined; + /** Download response kept independent of Node's global fetch implementation. */ export interface LinuxHopperDownload { readonly ok: boolean; @@ -141,16 +157,19 @@ export const parseLinuxDistribution = (text: string): LinuxDistribution => { const key = match[1]; const encoded = match[2]; if (key === undefined || encoded === undefined) continue; - const value = - encoded.startsWith('"') && encoded.endsWith('"') - ? encoded.slice(1, -1).replaceAll('\\"', '"').replaceAll("\\\\", "\\") - : encoded; + const quote = encoded[0]; + const quoted = (quote === "'" || quote === '"') && encoded.endsWith(quote); + const value = !quoted + ? encoded + : quote === "'" + ? encoded.slice(1, -1) + : encoded.slice(1, -1).replace(/\\(["\\$`])/gu, "$1"); fields.set(key, value); } const id = (fields.get("ID") ?? "unknown").toLowerCase(); const versionId = fields.get("VERSION_ID"); const packageFamily = packageFamilyFor(id, fields.get("ID_LIKE")); - const supported = + const hopperSetupSupported = packageFamily !== undefined && (id === "arch" || id === "cachyos" || @@ -162,9 +181,9 @@ export const parseLinuxDistribution = (text: string): LinuxDistribution => { id, ...(versionId === undefined ? {} : { versionId }), }; - return supported - ? { ...identity, packageFamily, supported: true } - : { ...identity, packageFamily, supported: false }; + return hopperSetupSupported + ? { ...identity, packageFamily, hopperSetupSupported: true } + : { ...identity, packageFamily, hopperSetupSupported: false }; }; /** Read and classify the current Linux distribution. */ @@ -193,7 +212,7 @@ export const installLinuxHopper = async ( if (options.signal?.aborted === true) return { status: "failed", reason: "cancelled" }; const distribution = await host.distribution(); - if (distribution?.supported !== true) + if (distribution?.hopperSetupSupported !== true) return { status: "failed", reason: "unsupported_host" }; let temporary: string | undefined; try { diff --git a/src/application/Setup.configuration.test.ts b/src/application/Setup.configuration.test.ts index 3bd08cd4f..a3f46ed6f 100644 --- a/src/application/Setup.configuration.test.ts +++ b/src/application/Setup.configuration.test.ts @@ -57,7 +57,7 @@ describe("setup workflow", () => { id: "ubuntu", versionId: "24.04", packageFamily: "deb", - supported: true, + hopperSetupSupported: true, }; host.hopper = "/opt/hopper/bin/Hopper"; host.linuxDemoRuntimeMissing = true; @@ -79,7 +79,7 @@ describe("setup workflow", () => { id: "ubuntu", versionId: "24.04", packageFamily: "deb", - supported: true, + hopperSetupSupported: true, }; host.hopper = "/custom/Hopper"; host.unsupportedHopperVersion = true; diff --git a/src/application/Setup.planning.test.ts b/src/application/Setup.planning.test.ts index d1ca6baea..2548772c9 100644 --- a/src/application/Setup.planning.test.ts +++ b/src/application/Setup.planning.test.ts @@ -70,7 +70,7 @@ describe("setup workflow", () => { id: "ubuntu", versionId: "24.04", packageFamily: "deb", - supported: true, + hopperSetupSupported: true, }; const result = await runSetup( { ...options(false), structured: false }, @@ -162,7 +162,7 @@ describe("setup workflow", () => { id: "ubuntu", versionId: "24.04", packageFamily: "deb", - supported: true, + hopperSetupSupported: true, }; const result = await runSetup(options(true, true), host); expect(result.appliedActions).toEqual(["installed_hopper"]); @@ -236,7 +236,7 @@ describe("setup workflow lifecycle and provider planning", () => { id: "ubuntu", versionId: "24.04", packageFamily: "deb", - supported: true, + hopperSetupSupported: true, }; host.ghidra = "/opt/ghidra_12.1.4_PUBLIC"; host.javaHome = "/usr/lib/jvm/jdk-21"; diff --git a/src/application/Setup.recovery.test.ts b/src/application/Setup.recovery.test.ts index f29c9d375..244aee26b 100644 --- a/src/application/Setup.recovery.test.ts +++ b/src/application/Setup.recovery.test.ts @@ -310,7 +310,7 @@ describe("setup scoped readiness", () => { id: "debian", versionId: "13", packageFamily: "deb", - supported: false, + hopperSetupSupported: false, }; const result = await runSetup(options(true, true), host); expect(result.status).toBe("needs_human"); @@ -326,7 +326,7 @@ describe("setup scoped readiness", () => { id: "debian", versionId: "13", packageFamily: "deb", - supported: false, + hopperSetupSupported: false, }; host.ghidra = "/opt/ghidra_12.1.4_PUBLIC"; host.javaHome = "/usr/lib/jvm/jdk-21"; diff --git a/src/application/SetupClientConfiguration.ts b/src/application/SetupClientConfiguration.ts index a868c6177..90f4bf4ca 100644 --- a/src/application/SetupClientConfiguration.ts +++ b/src/application/SetupClientConfiguration.ts @@ -704,6 +704,14 @@ const clientConfigurationDesired = ( launcher, environment.value, ); + const previous = parsed?.servers[PRODUCT_IDENTITY.mcpServerKey]; + if ( + client.name === "kimi" && + typeof previous === "object" && + previous !== null && + "enabledTools" in previous + ) + registration.enabledTools = previous.enabledTools; return { ...registration, ...(client.name === "codex" || client.name === "grok_build" @@ -711,5 +719,11 @@ const clientConfigurationDesired = ( startup_timeout_sec: MCP_STARTUP_POLICY.codexStartupTimeoutSeconds, } : {}), + ...(client.name === "kimi" + ? { + startupTimeoutMs: MCP_STARTUP_POLICY.kimiStartupTimeoutMs, + toolTimeoutMs: MCP_STARTUP_POLICY.kimiToolTimeoutMs, + } + : {}), }; }; diff --git a/src/application/SetupHost.ts b/src/application/SetupHost.ts index 022cecc62..84d41229e 100644 --- a/src/application/SetupHost.ts +++ b/src/application/SetupHost.ts @@ -124,7 +124,8 @@ export const hostRemediation = async ( ? "Upgrade to macOS 12 or newer." : undefined; } - if ((await host.linuxDistribution())?.supported === true) return undefined; + if ((await host.linuxDistribution())?.hopperSetupSupported === true) + return undefined; return "Automated Hopper setup supports Ubuntu 24.04+, Fedora 41+, Nobara 44+, 64-bit Arch Linux, CachyOS, and Omarchy; configure an existing supported provider instead."; }; diff --git a/src/application/SetupPlan.ts b/src/application/SetupPlan.ts index 35db28de4..f428ecade 100644 --- a/src/application/SetupPlan.ts +++ b/src/application/SetupPlan.ts @@ -56,7 +56,8 @@ export const discoverSetupState = async (input: { const hopperInstallSupported = (input.host.platform === "darwin" && majorVersion((await input.host.macosVersion()) ?? "0") >= 12) || - (input.host.platform === "linux" && linuxDistribution?.supported === true); + (input.host.platform === "linux" && + linuxDistribution?.hopperSetupSupported === true); const installHopper = input.forceHopperInstall || (hopperInstallSupported && diff --git a/src/application/SetupSkill.ts b/src/application/SetupSkill.ts index 83ba96c5a..794202b48 100644 --- a/src/application/SetupSkill.ts +++ b/src/application/SetupSkill.ts @@ -72,6 +72,15 @@ interface CanonicalSkillFile { readonly original: SkillFileState | undefined; } +const canonicalSkillContent = (relativePath: string): Promise => + readFile( + new URL( + `../../skills/${PRODUCT_IDENTITY.skillName}/${relativePath}`, + import.meta.url, + ), + "utf8", + ); + export interface SkillInstallFileOperations { readonly mkdir: (path: string) => Promise; readonly backup: (source: string, destination: string) => Promise; @@ -244,19 +253,36 @@ const canonicalSkillFiles = async ( const destination = join(root, relativePath); return { destination, - content: await readFile( - new URL( - `../../skills/${PRODUCT_IDENTITY.skillName}/${relativePath}`, - import.meta.url, - ), - "utf8", - ), + content: await canonicalSkillContent(relativePath), original: await readOptionalSkillState(destination, resources), }; }), ), ); +const canonicalSkillNeedsInstallByReading = async ( + home: string, + clientIds: readonly string[], + environment: Readonly, + platform: NodeJS.Platform, + resources?: ArtifactResourceScope, +): Promise => { + for (const { path: root } of skillDestinations( + home, + clientIds, + environment, + platform, + )) { + for (const relativePath of MANAGED_SKILL_FILES) { + const destination = join(root, relativePath); + const content = await canonicalSkillContent(relativePath); + const original = await readOptionalSkillState(destination, resources); + if (original?.content !== content) return true; + } + } + return false; +}; + /** Report whether setup would change any file in the managed REA skill bundle. */ export const canonicalSkillNeedsInstall = async ( home: string, @@ -266,15 +292,13 @@ export const canonicalSkillNeedsInstall = async ( resources?: ArtifactResourceScope, ): Promise => { try { - return ( - await canonicalSkillFiles( - home, - clientIds, - environment, - platform, - resources, - ) - ).some(({ content, original }) => original?.content !== content); + return await canonicalSkillNeedsInstallByReading( + home, + clientIds, + environment, + platform, + resources, + ); } catch (cause: unknown) { if ( cause instanceof RegularFileCleanupFailure || diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts index 8407da4c0..55a651a52 100644 --- a/src/application/SupportedClients.ts +++ b/src/application/SupportedClients.ts @@ -45,6 +45,7 @@ export interface SetupClient { | "grok" | "omp" | "pi" + | "kimi" | "hermes" | "zcode" | "dsh" @@ -165,6 +166,10 @@ const claudeCodeMarkerDirectory = ({ home, env }: ClientPathContext): string => const codexDirectory = ({ home, env }: ClientPathContext): string => env.CODEX_HOME || join(home, ".codex"); +/** Kimi Code stores MCP declarations beside the rest of its home data. */ +const kimiDirectory = ({ home, env }: ClientPathContext): string => + env.KIMI_CODE_HOME || join(home, ".kimi-code"); + const qoderDirectory = ({ home, env }: ClientPathContext): string => env.QODER_CONFIG_DIR || join(home, ".qoder"); @@ -576,6 +581,14 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ markerPath: qwenCodeDirectory, format: "json", }, + { + name: "kimi", + displayName: "Kimi Code", + configPath: (context: ClientPathContext) => + join(kimiDirectory(context), "mcp.json"), + markerPath: kimiDirectory, + format: "kimi", + }, { name: "vscode", displayName: "VS Code", diff --git a/src/application/Uninstall.ts b/src/application/Uninstall.ts index a4561c45e..eac36c73d 100644 --- a/src/application/Uninstall.ts +++ b/src/application/Uninstall.ts @@ -627,11 +627,16 @@ const removeManagedSkills = async ( platform: NodeJS.Platform, writeBlocker?: (path: string) => string | undefined, ): Promise => { - // Every destination shares one summary item, so each result is labeled by - // the directory it refers to. + // Every destination shares one summary item; preserve both client and path. const results = await Promise.all( - skillDestinations(home, undefined, environment, platform).map(({ path }) => - removeManagedSkill(path, path, fileSystem, writeBlocker), + skillDestinations(home, undefined, environment, platform).map( + ({ client, path }) => + removeManagedSkill( + path, + `${client} (${path})`, + fileSystem, + writeBlocker, + ), ), ); const failed = results.find(({ status }) => status === "failed"); diff --git a/src/application/javascript/JavaScriptApplicationEvidence.ts b/src/application/javascript/JavaScriptApplicationEvidence.ts index b812f237e..ca20b813f 100644 --- a/src/application/javascript/JavaScriptApplicationEvidence.ts +++ b/src/application/javascript/JavaScriptApplicationEvidence.ts @@ -100,4 +100,10 @@ const parameters = ( ...(input.source_paths === undefined ? {} : { source_paths: input.source_paths }), + ...(input.max_heap_mb === undefined + ? {} + : { max_heap_mb: input.max_heap_mb }), + ...(input.analysis_timeout_ms === undefined + ? {} + : { analysis_timeout_ms: input.analysis_timeout_ms }), }); diff --git a/src/application/javascript/JavaScriptApplicationService.ts b/src/application/javascript/JavaScriptApplicationService.ts index ea56ce591..fa66a5521 100644 --- a/src/application/javascript/JavaScriptApplicationService.ts +++ b/src/application/javascript/JavaScriptApplicationService.ts @@ -62,26 +62,7 @@ export const analyzeJavaScriptApplicationValidated = async ( }); await checkpointJavaScriptAnalysis(options.signal); reconstructed = await reconstructJavaScriptArtifact( - { - input_path: input.input_path, - format: input.format, - integrity_policy: input.integrity_policy, - ...(input.max_semantic_nodes === undefined - ? {} - : { max_semantic_nodes: input.max_semantic_nodes }), - ...(input.max_semantic_nodes_per_file === undefined - ? {} - : { max_semantic_nodes_per_file: input.max_semantic_nodes_per_file }), - ...(input.source_paths === undefined - ? {} - : { source_paths: input.source_paths }), - ...(input.max_heap_mb === undefined - ? {} - : { max_heap_mb: input.max_heap_mb }), - ...(input.analysis_timeout_ms === undefined - ? {} - : { analysis_timeout_ms: input.analysis_timeout_ms }), - }, + input, scope, options.signal, options.progress, diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.ts b/src/application/javascript/JavaScriptArtifactPathResolution.ts index cb29ab2a9..88aa1508f 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.ts @@ -457,9 +457,8 @@ const bareModuleCandidate = ( dependencyDirectory = posix.dirname(confined); } const source = input.files.get(input.sourcePath); - let directory = posix.dirname(input.sourcePath); let nearestNotFound: CandidateResolution | null = null; - while (true) { + for (const directory of packageLookupDirectories(input)) { const candidate = posix.join(directory, dependencyDirectory); const metadata = readPackageMetadata(input, candidate); const continueCommonJsLookup = @@ -487,8 +486,6 @@ const bareModuleCandidate = ( nearestNotFound ??= packageResolution; } else return packageResolution; } - if (directory === "." || directory === "") break; - directory = posix.dirname(directory); } if (nearestNotFound !== null) return nearestNotFound; return unresolvedCandidate("external", [ @@ -496,6 +493,21 @@ const bareModuleCandidate = ( ]); }; +function* packageLookupDirectories( + input: ResolveArtifactPathInput, +): Generator { + let directory = posix.dirname(input.sourcePath); + while (true) { + if ( + input.moduleKind !== "require" || + posix.basename(directory) !== "node_modules" + ) + yield directory; + if (directory === "." || directory === "") return; + directory = posix.dirname(directory); + } +} + /** * Nearest same-container package.json, stopping at a `node_modules` directory * so a nested package cannot inherit an ancestor's map. @@ -960,15 +972,29 @@ const resolveCandidate = ( candidate: string, packageChain: ReadonlySet, emittedSourceCandidates: readonly TypeScriptEmittedSourceCandidate[] = [], -): CandidateResolution => - resolveFileCandidates(input, fileCandidates(candidate, input.moduleKind)) ?? - resolveRuntimeSourceCandidates(input, candidate, emittedSourceCandidates) ?? - resolveDirectory( +): CandidateResolution => { + if (!commonJsDirectoryRequest(input)) { + const file = + resolveFileCandidates( + input, + fileCandidates(candidate, input.moduleKind), + ) ?? + resolveRuntimeSourceCandidates(input, candidate, emittedSourceCandidates); + if (file !== null) return file; + } + return resolveDirectory( input, candidate, readPackageMetadata(input, candidate), packageChain, ); +}; + +const commonJsDirectoryRequest = (input: ResolveArtifactPathInput): boolean => + input.context === "module-specifier" && + input.moduleKind === "require" && + (input.declaredPath.endsWith("/") || + /(?:^|\/)\.{1,2}$/u.test(input.declaredPath)); const resolveDirectory = ( input: ResolveArtifactPathInput, diff --git a/src/application/javascript/JavaScriptArtifactReconstruction.ts b/src/application/javascript/JavaScriptArtifactReconstruction.ts index bd95415c6..a5528ee10 100644 --- a/src/application/javascript/JavaScriptArtifactReconstruction.ts +++ b/src/application/javascript/JavaScriptArtifactReconstruction.ts @@ -277,15 +277,9 @@ export const reconstructJavaScriptArtifact = async ( limitations: completed.graph.limitations, }), ); - const { max_heap_mb, analysis_timeout_ms, ...coreInput } = input; - const parameters = { - ...coreInput, - ...(max_heap_mb === undefined ? {} : { max_heap_mb }), - ...(analysis_timeout_ms === undefined ? {} : { analysis_timeout_ms }), - }; const evidence = await createOwnedJavaScriptApplicationEvidenceCooperatively( - parameters, + input, partial, ); throw interruption.retainPartialObservation(evidence); diff --git a/src/cli/coreNativeCommands.ts b/src/cli/coreNativeCommands.ts index 016eedbc0..80d07187f 100644 --- a/src/cli/coreNativeCommands.ts +++ b/src/cli/coreNativeCommands.ts @@ -17,6 +17,46 @@ export const registerCoreNativeCommands = ( runDirectAnalysis: DirectAnalysis["runDirectAnalysis"], ): void => { registerAnnotationCommand(cli, logger, runDirectAnalysis); + cli.command(CLI_COMMANDS.searchBytes, { + description: "Find masked byte patterns in initialized provider memory", + args: z.object({ + path: z.string().describe("Local executable path"), + pattern: z.string().describe("Hexadecimal byte pairs with ?? wildcards"), + }), + options: z.object({ + mask: z.string().optional().describe("Equal-length hexadecimal bit mask"), + start: z.string().optional().describe("Inclusive start address"), + end: z.string().optional().describe("Inclusive end address"), + cursor: z + .string() + .optional() + .describe("Continuation from the same query's next_cursor"), + limit: z + .number() + .int() + .min(1) + .max(1000) + .default(100) + .describe("Maximum matches on this page"), + ...nativeOpenOptionFields, + }), + run: ({ args, options }) => + logCliCommand(logger, CLI_COMMANDS.searchBytes, () => + runDirectAnalysis( + args.path, + "search_bytes", + { + pattern: args.pattern, + limit: options.limit, + ...(options.mask === undefined ? {} : { mask: options.mask }), + ...(options.start === undefined ? {} : { start: options.start }), + ...(options.end === undefined ? {} : { end: options.end }), + ...(options.cursor === undefined ? {} : { cursor: options.cursor }), + }, + directAnalysisOptionsFromCli(logger, options), + ), + ), + }); cli.command(CLI_COMMANDS.inspectNativeLoadImage, { description: "Verify loaded native bytes, source mappings, relocations and entry", diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index d63a9a2bc..b3f2cb0a6 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -39,6 +39,7 @@ export const CLI_COMMANDS = Object.freeze({ inspectNativeDataType: "inspect-native-data-type", inspectNativeLoadImage: "inspect-native-load-image", readBytes: "read-bytes", + searchBytes: "search-bytes", addressToFileOffset: "address-to-file-offset", traceNativeValues: "trace-native-values", inspectNativeInstruction: "inspect-native-instruction", diff --git a/src/contracts/hopperMemoryContracts.ts b/src/contracts/hopperMemoryContracts.ts index e4c49788a..df7e43169 100644 --- a/src/contracts/hopperMemoryContracts.ts +++ b/src/contracts/hopperMemoryContracts.ts @@ -1,8 +1,27 @@ import { z } from "zod"; import { address, document } from "./toolContractHelpers.js"; +import { + bytePatternFields, + validateBytePattern, +} from "../domain/native/bytePattern.js"; /** Hopper memory and file-mapping contracts. */ export const HOPPER_MEMORY_TOOL_DEFINITIONS = [ + { + name: "search_bytes", + description: + "Find masked byte signatures in initialized analyzed memory. Hex byte pairs and ?? wildcards are supported; an optional equal-length hex mask selects bits. Bounds are inclusive. Matches are ordered by provider address and never span blocks. Coverage describes this page; reuse the same query with next_cursor to continue. Uninitialized blocks are listed separately. Ghidra only; Hopper is unavailable.", + inputSchema: z + .strictObject({ + ...bytePatternFields, + document, + start: address.optional(), + end: address.optional(), + limit: z.number().int().min(1).max(1000).default(100), + cursor: z.string().min(1).optional(), + }) + .superRefine(validateBytePattern), + }, { name: "read_bytes", description: diff --git a/src/contracts/toolContractExamples.ts b/src/contracts/toolContractExamples.ts index 66b0f3518..50a745e75 100644 --- a/src/contracts/toolContractExamples.ts +++ b/src/contracts/toolContractExamples.ts @@ -26,6 +26,7 @@ export const TOOL_EXAMPLE_OVERRIDES: Readonly< resolve_native_call_targets: { address: "0x1000" }, read_function_instructions: { procedure: "main" }, read_bytes: { address: "0x1000", length: 16 }, + search_bytes: { pattern: "48 8B ?? ?? 89" }, address_to_file_offset: { address: "0x1000" }, procedure_references: { procedure: "main" }, procedure_pseudo_code: { procedure: "main" }, diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index 70a40c6a1..939f123eb 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -438,6 +438,7 @@ export const TOOL_EFFECTS: Readonly> = { inspect_native_data_type: evidence, resolve_native_call_targets: evidence, read_bytes: evidence, + search_bytes: evidence, address_to_file_offset: evidence, procedure_references: evidence, procedure_pseudo_code: evidence, diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index 76294e2db..1a36c0896 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { byteSearchResultSchema } from "../domain/native/bytePattern.js"; import { artifactInventorySummaryViewResultSchema, machoSummaryViewResultSchema, @@ -108,6 +109,7 @@ const bookmarkFacetSchema = z.discriminatedUnion("state", [ /** Exact structured-content schemas shared by direct analysis providers. */ export const officialOutputSchemas: Readonly> = { + search_bytes: evidenceResultOf(byteSearchResultSchema), annotate_native_function: evidenceResultOf(nativeFunctionAnnotationsSchema), inspect_native_load_image: evidenceResultOf(nativeLoadImageSchema), inspect_native_data_type: evidenceResultOf(nativeDataTypeSchema), diff --git a/src/domain/apple/appleApplication.test.ts b/src/domain/apple/appleApplication.test.ts index 9caf21fcb..3f8906e2b 100644 --- a/src/domain/apple/appleApplication.test.ts +++ b/src/domain/apple/appleApplication.test.ts @@ -417,6 +417,34 @@ describe("Apple bundle layouts and containers", () => { }); }); +describe("Apple IPA components", () => { + it("excludes AppleDouble sidecars from IPA components", () => { + const result = project( + inventoryEvidence("ipa", "Fixture.ipa", [ + { path: "Payload/Fixture.app/Info.plist" }, + macho("Payload/Fixture.app/Fixture"), + { path: "Payload/Fixture.app/Contents/Resources/._libfoo.dylib" }, + { path: "Payload/Fixture.app/Frameworks/Foo.framework/._Foo" }, + { path: "__MACOSX/Payload/Fixture.app/libghost.dylib" }, + macho("Payload/Fixture.app/Contents/Resources/libreal.dylib"), + macho("SwiftSupport/iphoneos/libswiftCore.dylib"), + ]), + ); + expect(result.components.native_libraries.map(({ path }) => path)).toEqual([ + "Payload/Fixture.app/Contents/Resources/libreal.dylib", + "SwiftSupport/iphoneos/libswiftCore.dylib", + ]); + expect(result.components.frameworks).toEqual([]); + expect(result.components.executables.map(({ path }) => path)).toEqual([ + "Payload/Fixture.app/Contents/Resources/libreal.dylib", + "Payload/Fixture.app/Fixture", + ]); + expect(result.limitations).toContainEqual( + expect.stringContaining("AppleDouble sidecar entries"), + ); + }); +}); + describe("macOS application scope", () => { it("attributes only components inside the application roots", () => { const result = project( diff --git a/src/domain/apple/appleApplication.ts b/src/domain/apple/appleApplication.ts index d46ddf26b..dfb34e3c5 100644 --- a/src/domain/apple/appleApplication.ts +++ b/src/domain/apple/appleApplication.ts @@ -218,8 +218,9 @@ export const appleProjectionId = (projection: unknown): string => `aap_${digest(projection)}`; /** - * IPA projection keeps every archive component, as before. Directory, ZIP, - * and DMG inventories can hold installers and other siblings, so only + * AppleDouble sidecars describe neighbouring files and are never components. + * IPA projection keeps every other archive component, as before. Directory, + * ZIP, and DMG inventories can hold installers and other siblings, so only * components inside an application root are attributed to it. */ const attributeComponents = ( @@ -227,8 +228,8 @@ const attributeComponents = ( roots: readonly string[], rootFormat: RootFormat, ): { readonly all: Component[]; readonly unattributed: number } => { - if (rootFormat === "ipa") return { all: [...inventoried], unattributed: 0 }; const visible = inventoried.filter(({ path }) => !isSidecar(path)); + if (rootFormat === "ipa") return { all: visible, unattributed: 0 }; const all = visible.filter(({ path }) => roots.some((root) => isWithin(path, root)), ); diff --git a/src/domain/hopperValues.ts b/src/domain/hopperValues.ts index cb19ead3e..b42a98383 100644 --- a/src/domain/hopperValues.ts +++ b/src/domain/hopperValues.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { addressCoordinate } from "./native/addressCoordinate.js"; import type { JsonValue } from "./jsonValue.js"; @@ -38,16 +39,6 @@ export const addressDistance = (start: string, end: string): number => { ); }; -const addressCoordinate = ( - value: string, -): { readonly space: string; readonly offset: bigint } | null => { - const separator = value.lastIndexOf(":0x"); - const space = separator < 0 ? "default" : value.slice(0, separator); - const offset = separator < 0 ? value : value.slice(separator + 1); - if (!/^0x[0-9a-f]+$/u.test(offset)) return null; - return { space, offset: BigInt(offset) }; -}; - const procedureMapSchema = z.record(z.string(), z.string()); const addressedNamesSchema = z.array( z.object({ address: z.string(), name: z.string() }), @@ -128,9 +119,9 @@ export const functionBodySchema = z.discriminatedUnion("available", [ }) .superRefine((body, context) => { let total = 0n; - const bounds = new Map(); - const closedSpaces = new Set(); - let previousSpace: string | null = null; + const bounds = new Map(); + const closedSpaces = new Set(); + let previousSpace: string | null | undefined; for (const range of body.ranges) { const start = addressCoordinate(range.start); const end = addressCoordinate(range.end); @@ -146,7 +137,7 @@ export const functionBodySchema = z.discriminatedUnion("available", [ }); return; } - if (previousSpace !== null && previousSpace !== start.space) + if (previousSpace !== undefined && previousSpace !== start.space) closedSpaces.add(previousSpace); const previous = bounds.get(start.space); if ( diff --git a/src/domain/javascript/javascriptSemanticChildProcesses.ts b/src/domain/javascript/javascriptSemanticChildProcesses.ts index 0fd0c9a35..1cffb5164 100644 --- a/src/domain/javascript/javascriptSemanticChildProcesses.ts +++ b/src/domain/javascript/javascriptSemanticChildProcesses.ts @@ -339,7 +339,10 @@ const spawnOptions = (candidate: SpawnCandidate): t.ObjectExpression | null => { const spawnArgvCount = (candidate: SpawnCandidate): number | null => { if (candidate.method === "exec") return null; const args = candidate.node.arguments[1]; - return t.isArrayExpression(args) ? args.elements.length : null; + return t.isArrayExpression(args) && + !args.elements.some((element) => t.isSpreadElement(element)) + ? args.elements.length + : null; }; const objectHasProperty = ( diff --git a/src/domain/native/addressCoordinate.ts b/src/domain/native/addressCoordinate.ts new file mode 100644 index 000000000..d43b1b4f1 --- /dev/null +++ b/src/domain/native/addressCoordinate.ts @@ -0,0 +1,11 @@ +/** Parse canonical native offsets without discarding their address space. */ +export const addressCoordinate = ( + value: string, +): { readonly space: string | null; readonly offset: bigint } | null => { + const separator = value.lastIndexOf(":0x"); + if (separator === 0) return null; + const space = separator < 0 ? null : value.slice(0, separator); + const offset = separator < 0 ? value : value.slice(separator + 1); + if (!/^0x[0-9a-f]+$/u.test(offset)) return null; + return { space, offset: BigInt(offset) }; +}; diff --git a/src/domain/native/bytePattern.test.ts b/src/domain/native/bytePattern.test.ts new file mode 100644 index 000000000..cc3b6dd9a --- /dev/null +++ b/src/domain/native/bytePattern.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { parseBytePattern } from "./bytePattern.js"; + +describe("masked byte patterns", () => { + it("preserves whole-byte wildcards and combines explicit bit masks", () => { + const parsed = parseBytePattern("48 8b\t??89", "ff f0 ff 0f"); + if (!parsed.ok) throw parsed.error; + expect(parsed.value).toEqual({ + bytes: [0x48, 0x8b, 0, 0x89], + masks: [255, 240, 0, 15], + }); + const exact = parseBytePattern("Aa\u00a000"); + if (!exact.ok) throw exact.error; + expect(exact.value).toEqual({ bytes: [170, 0], masks: [255, 255] }); + }); + + it.each([ + ["", undefined, "pattern"], + ["4", undefined, "pattern"], + ["4?", undefined, "pattern"], + ["gg", undefined, "pattern"], + ["?? ??", undefined, "pattern"], + ["ff", "00", "pattern"], + ["ffff", "ff", "mask"], + ["ff", "??", "mask"], + ])("rejects invalid pattern %s with typed issues", (pattern, mask, field) => { + const parsed = parseBytePattern(pattern, mask); + if (parsed.ok) throw new Error("Expected a rejected pattern"); + expect(parsed.error.issues).toMatchObject([ + { path: [field], reason: "invalid_value" }, + ]); + }); +}); diff --git a/src/domain/native/bytePattern.ts b/src/domain/native/bytePattern.ts new file mode 100644 index 000000000..add5bb7ef --- /dev/null +++ b/src/domain/native/bytePattern.ts @@ -0,0 +1,125 @@ +import { z } from "zod"; +import { addressCoordinate } from "./addressCoordinate.js"; +import { AnalysisInputError } from "../analysisErrorCore.js"; +import { err, ok, type Result } from "../result.js"; + +/** Byte pairs, with whole-byte wildcards; explicit masks can select individual bits. */ +export const bytePatternFields = { + pattern: z + .string() + .regex(/^([0-9a-fA-F]{2}|\?\?)(\s*([0-9a-fA-F]{2}|\?\?))*$/u), + mask: z + .string() + .regex(/^[0-9a-fA-F]{2}(\s*[0-9a-fA-F]{2})*$/u) + .optional(), +}; + +/** Parse once at the input boundary, retaining typed correction issues. */ +export const parseBytePattern = ( + pattern: string, + mask?: string, +): Result<{ bytes: number[]; masks: number[] }, AnalysisInputError> => { + const invalid = (field: string, message: string) => + err( + new AnalysisInputError("search_bytes", undefined, [ + { path: [field], reason: "invalid_value", message }, + ]), + ); + if (!bytePatternFields.pattern.safeParse(pattern).success) + return invalid("pattern", "Use hexadecimal byte pairs or ?? wildcards"); + if (mask !== undefined && !bytePatternFields.mask.safeParse(mask).success) + return invalid("mask", "Use hexadecimal mask byte pairs"); + const pairs = pattern.replace(/\s/gu, "").match(/../gu) ?? []; + const maskPairs = mask?.replace(/\s/gu, "").match(/../gu); + if (maskPairs && maskPairs.length !== pairs.length) + return invalid("mask", "Mask length must equal pattern length"); + const bytes = pairs.map((pair) => + pair === "??" ? 0 : Number.parseInt(pair, 16), + ); + const masks = pairs.map((pair, index) => + pair === "??" ? 0 : Number.parseInt(maskPairs?.[index] ?? "ff", 16), + ); + if (!masks.some((value) => value !== 0)) + return invalid("pattern", "Pattern must constrain at least one bit"); + return ok({ bytes, masks }); +}; + +/** Shared semantic validation for public and provider inputs. */ +export const validateBytePattern = ( + value: { pattern: string; mask?: string | undefined }, + context: z.RefinementCtx, +): void => { + const parsed = parseBytePattern(value.pattern, value.mask); + if (!parsed.ok) + for (const issue of parsed.error.issues) + context.addIssue({ + code: "custom", + path: [...issue.path], + message: issue.message ?? "Invalid byte pattern", + }); +}; + +const coverageBlock = z + .strictObject({ + segment: z.string(), + start: z.string(), + end: z.string(), + bytes: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + }) + .superRefine((value, context) => { + const start = addressCoordinate(value.start); + const end = addressCoordinate(value.end); + if ( + start === null || + end === null || + start.space !== end.space || + start.offset > end.offset || + end.offset - start.offset + 1n !== BigInt(value.bytes) + ) + context.addIssue({ + code: "custom", + message: + "Byte coverage must equal its inclusive address range in one space", + }); + }); + +/** Inclusive page coverage; matches never span memory blocks. */ +export const byteSearchResultSchema = z + .strictObject({ + matches: z + .array( + z.strictObject({ + address: z.string(), + segment: z.string(), + file_offset: z + .number() + .int() + .nonnegative() + .max(Number.MAX_SAFE_INTEGER) + .nullable(), + procedure: z + .strictObject({ address: z.string(), name: z.string() }) + .nullable(), + }), + ) + .max(1000), + searched: z.strictObject({ + blocks: z.array(coverageBlock), + bytes: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + skipped_uninitialized: z.array(coverageBlock), + skipped_not_loaded: z.array(coverageBlock), + }), + complete: z.boolean(), + next_cursor: z.string().min(1).nullable(), + }) + .superRefine((value, context) => { + if ( + value.complete !== (value.next_cursor === null) || + value.searched.bytes !== + value.searched.blocks.reduce((sum, block) => sum + block.bytes, 0) + ) + context.addIssue({ + code: "custom", + message: "Byte search coverage and continuation disagree", + }); + }); diff --git a/src/domain/reconstructionVerification.functionSources.test.ts b/src/domain/reconstructionVerification.functionSources.test.ts index 5dc8db589..e23d37d74 100644 --- a/src/domain/reconstructionVerification.functionSources.test.ts +++ b/src/domain/reconstructionVerification.functionSources.test.ts @@ -53,6 +53,7 @@ const source = ( digit: string, observation: Partial = {}, hasSubject = true, + hasCoverage = true, ): Evidence => createEvidence( hasSubject @@ -69,7 +70,9 @@ const source = ( result: dossier, confidence: "derived", authority: "shipped-artifact", - relationshipCoverage: { status: "complete" }, + ...(hasCoverage + ? { relationshipCoverage: { status: "complete" as const } } + : {}), ...observation, }, ); @@ -197,6 +200,118 @@ describe("function reconstruction source parameters", () => { }); }); +describe("historical calls-only reconstruction changes", () => { + it.each(["missing", "unknown", "partial", "unresolved"] as const)( + "keeps historical calls-only changes unknown with %s call coverage", + (coverage) => { + const changedDossier = functionDossierSchema.parse({ + ...functionDossierSchema.parse(dossier), + callees: [ + { + address: "0x2000", + name: "puts", + classification: null, + body: { available: false, reason: "External function." }, + }, + ], + }); + const complete = source("8", { + result: jsonValueSchema.parse(changedDossier), + }); + const incomplete = source( + "9", + { + result: jsonValueSchema.parse({ + ...changedDossier, + ...(coverage === "unresolved" + ? { + unresolved_calls: [ + { + address: "0x1004", + reason: "Computed target unresolved.", + }, + ], + } + : {}), + }), + ...(coverage === "missing" + ? {} + : { + relationshipCoverage: + coverage === "unknown" + ? { status: "unknown", reason: "Call graph unavailable." } + : coverage === "partial" + ? { + status: "partial", + limitations: ["Additional edges omitted."], + } + : { status: "complete" }, + }), + }, + true, + coverage !== "missing", + ); + const historicalResult = compareFunctions(left, complete); + expect(historicalResult).toMatchObject({ + status: "changed", + summary: { changed: 1 }, + }); + expect(historicalResult.changes).toMatchObject([ + { dimension: "calls", status: "changed" }, + ]); + const completeComparison = comparison( + { + left_evidence_id: left.evidence_id, + right_evidence_id: complete.evidence_id, + }, + {}, + [left, complete], + {}, + historicalResult, + ); + expect( + admittedVerification(completeComparison, [left, complete], "overall")() + .status, + ).toBe("fail"); + const evidence = comparison( + { + left_evidence_id: left.evidence_id, + right_evidence_id: incomplete.evidence_id, + }, + {}, + [left, incomplete], + {}, + historicalResult, + ); + const before = JSON.stringify(evidence); + for (const dimension of ["calls", "overall"] as const) { + expect( + admittedVerification(evidence, [left, incomplete], dimension)(), + ).toMatchObject({ + status: "unknown", + summary: { total: 1, passed: 0, failed: 0, unknown: 1 }, + claims: { + items: [ + expect.objectContaining({ + status: "unknown", + observed_status: "changed", + limitations: expect.arrayContaining([ + expect.stringContaining(`Source ${incomplete.evidence_id}:`), + ]), + }), + ], + }, + }); + } + expect( + admittedVerification(evidence, [left, incomplete], "pseudocode")() + .status, + ).toBe("pass"); + expect(JSON.stringify(evidence)).toBe(before); + }, + ); +}); + describe("function reconstruction call coverage", () => { it("rechecks old unchanged comparisons without affecting unrelated finite dimensions", () => { const incomplete = source("7", { diff --git a/src/domain/reconstructionVerification.ts b/src/domain/reconstructionVerification.ts index dd300a0a2..6f693a158 100644 --- a/src/domain/reconstructionVerification.ts +++ b/src/domain/reconstructionVerification.ts @@ -144,10 +144,15 @@ const evaluateClaim = ( return source; }); const functionSourceFacts = readValidatedSourceFacts(claim, sources); - const observed = observedStatus(claim, comparison); + const observation = comparisonObservation(claim, comparison); + const observed = observation.status; const limitations = [ ...authorityLimitations(claim, sources, sides), - ...sourceCallCoverageLimitations(claim, functionSourceFacts, observed), + ...sourceCallCoverageLimitations( + claim, + functionSourceFacts, + observation.hasIndependentFunctionChange === true, + ), ]; const relatedUnknowns = reconstructionClaimUnknowns( heads, @@ -235,21 +240,40 @@ const validateComparisonIdentity = (claim: Claim, evidence: Evidence): void => { throw new TypeError("Comparison Evidence identity or authority disagrees"); }; -const observedStatus = (claim: Claim, evidence: Evidence): ObservedStatus => { +const comparisonObservation = ( + claim: Claim, + evidence: Evidence, +): { + readonly status: ObservedStatus; + readonly hasIndependentFunctionChange?: boolean; +} => { if (claim.kind === "behavioral") { const result = processCaptureComparisonSchema.parse( evidence.normalized_result, ); validateProcessStatus(result); - return claim.dimension === "overall" - ? result.status - : result[claim.dimension]; + return { + status: + claim.dimension === "overall" ? result.status : result[claim.dimension], + }; } if (claim.kind === "structural-function") { const result = functionComparisonResultSchema.parse( evidence.normalized_result, ); - if (claim.dimension === "overall") return result.status; + if (claim.dimension === "overall") + return { + status: result.status, + // Historical overall changes may come solely from incomplete call sets. + hasIndependentFunctionChange: + result.function_match.status === "mismatched" || + (result.function_match.status === "matched" && + result.dimensions.some( + (dimension) => + dimension.dimension !== "calls" && + dimension.status === "changed", + )), + }; const dimension = result.dimensions.find( (item) => item.dimension === claim.dimension, ); @@ -263,7 +287,7 @@ const observedStatus = (claim: Claim, evidence: Evidence): ObservedStatus => { throw new TypeError( "Function dimension cites Evidence outside comparison closure", ); - return dimension.status; + return { status: dimension.status }; } const result = artifactComparisonResultSchema.parse( evidence.normalized_result, @@ -276,7 +300,7 @@ const observedStatus = (claim: Claim, evidence: Evidence): ObservedStatus => { throw new TypeError( "Artifact change cites Evidence outside comparison closure", ); - return result.status; + return { status: result.status }; }; const validateProcessStatus = ( @@ -340,12 +364,12 @@ const authorityLimitations = ( const sourceCallCoverageLimitations = ( claim: Claim, functionSources: readonly FunctionSourceFacts[], - observed: ObservedStatus, + hasIndependentFunctionChange: boolean, ): string[] => { if ( claim.kind !== "structural-function" || (claim.dimension !== "calls" && - !(claim.dimension === "overall" && observed !== "changed")) + !(claim.dimension === "overall" && !hasIndependentFunctionChange)) ) return []; return functionSources.flatMap(({ evidence_id, callCoverageLimitations }) => diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index 3361cc366..edcb37a20 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -8,6 +8,48 @@ import { createWebTextArtifact } from "./webContentArtifact.js"; const origin = "https://app.example.test"; +it.each([ + { + kind: "recovered", + source: "function legacy(a, a) { return a; } fetch('/partial');", + parsed: 2, + failures: 0, + reason: "recovered", + endpoints: ["/partial", "/complete"], + }, + { + kind: "failed", + source: "function broken( {", + parsed: 1, + failures: 1, + reason: "could not be parsed", + endpoints: ["/complete"], + }, +])( + "attributes $kind parse uncertainty only to its affected source", + ({ source, parsed, failures, reason, endpoints }) => { + const captured = inspection(source); + const good = inspection("fetch('/complete');").scripts.items[0]; + if (good === undefined) throw new Error("Missing valid script fixture"); + captured.scripts.items.push({ ...good, script_key: "valid-script" }); + captured.scripts.total += 1; + const result = analyzeCapturedWebBundle(captured); + expect(result.completeness).toMatchObject({ + status: "partial", + parsed_scripts: parsed, + parse_failures: failures, + }); + expect(result.unknowns).toContainEqual({ + dimension: "javascript_ast", + reason: expect.stringContaining(reason), + affected_script_keys: [captured.scripts.items[0]?.script_key], + }); + expect(result.observations.endpoints.map(({ value }) => value)).toEqual( + endpoints, + ); + }, +); + describe("web bundle analyzer", () => { it("extracts graph, route, endpoint, vendor, and WebMCP evidence", () => { const source = ` @@ -714,3 +756,54 @@ it("keeps RPC lexical identity and bundled factory recognition in captured scrip ]); expect(result.completeness.status).toBe("complete"); }); + +it("retains distinct WebMCP descriptions and names while deduplicating equivalent properties", () => { + const result = analyzeCapturedWebBundle( + inspection(` + document.modelContext.registerTool({ name: "lookup", description: "first", inputSchema: { properties: { alpha: {} } } }); + document.modelContext.registerTool({ name: "lookup", description: "second", inputSchema: { properties: { alpha: {} } } }); + document.modelContext.registerTool({ name: "equivalent", inputSchema: { properties: { alpha: {}, beta: {} } } }); + document.modelContext.registerTool({ name: "equivalent", inputSchema: { properties: { beta: {}, alpha: {}, alpha: {} } } }); + document.modelContext.registerTool({ description: "unnamed" }); + document.modelContext.registerTool({ name: "", description: "unnamed" }); + `), + ); + + expect( + result.observations.webmcp_declarations.map( + ({ name, description, schema_property_names }) => ({ + name, + description, + schema_property_names, + }), + ), + ).toEqual([ + { + name: "lookup", + description: "first", + schema_property_names: ["alpha"], + }, + { + name: "lookup", + description: "second", + schema_property_names: ["alpha"], + }, + { + name: "equivalent", + description: null, + schema_property_names: ["alpha", "beta"], + }, + { name: null, description: "unnamed", schema_property_names: [] }, + { name: "", description: "unnamed", schema_property_names: [] }, + ]); + expect(result.observations.webmcp_declarations[2]?.location).toEqual({ + script_key: `scr_${"1".repeat(64)}`, + line: 4, + column: 6, + }); + expect( + result.observations.webmcp_declarations.every( + ({ trust }) => trust === "page-declared-untrusted", + ), + ).toBe(true); +}); diff --git a/src/domain/webBundleAnalyzerInspection.ts b/src/domain/webBundleAnalyzerInspection.ts index fd8c541ac..12c35109c 100644 --- a/src/domain/webBundleAnalyzerInspection.ts +++ b/src/domain/webBundleAnalyzerInspection.ts @@ -50,7 +50,8 @@ export interface AnalysisAccumulator { readonly seen: Set; visitedNodes: number; parsedScripts: number; - parseFailures: number; + readonly failedScripts: string[]; + readonly recoveredScripts: string[]; } export const analyzeScript = ( @@ -67,10 +68,12 @@ export const analyzeScript = ( } catch (cause: unknown) { // Unparseable scripts are counted; the failure needs no extra detail. void cause; - accumulator.parseFailures += 1; + accumulator.failedScripts.push(script.script_key); return; } accumulator.parsedScripts += 1; + if ((file.errors?.length ?? 0) > 0) + accumulator.recoveredScripts.push(script.script_key); detectVendorFingerprints(script, accumulator); const openReceiverFacts = classifyParsedJavaScriptOpenReceivers(file); const rpc = createJavaScriptRpcScan( @@ -293,19 +296,18 @@ const addWebMcpDeclaration = ( : [], ) : []; + const metadata = { + name: name ?? null, + description: description ?? null, + schema_property_names: [...new Set(schemaPropertyNames)].sort(), + }; + // Deduplicate the metadata we return, preserving omission and descriptions. // Property names are a variable-length source-derived list: NUL-joins // would collapse ["a\0b"] with ["a", "b"] and drop a declaration. - const key = compositeKey([ - "webmcp", - script.script_key, - name ?? "", - schemaPropertyNames, - ]); + const key = compositeKey(["webmcp", script.script_key, metadata]); addUnique(accumulator, key, () => accumulator.webMcp.push({ - name: name ?? null, - description: description ?? null, - schema_property_names: [...new Set(schemaPropertyNames)].sort(), + ...metadata, trust: "page-declared-untrusted", location: location(script.script_key, node), }), @@ -387,7 +389,8 @@ export const emptyAccumulator = (): AnalysisAccumulator => ({ seen: new Set(), visitedNodes: 0, parsedScripts: 0, - parseFailures: 0, + failedScripts: [], + recoveredScripts: [], }); export const isIncludedScript = ( diff --git a/src/domain/webBundleAnalyzerResult.ts b/src/domain/webBundleAnalyzerResult.ts index 1e08f0f11..4d2509675 100644 --- a/src/domain/webBundleAnalyzerResult.ts +++ b/src/domain/webBundleAnalyzerResult.ts @@ -25,7 +25,8 @@ export const buildWebBundleAnalysis = ( const sourceMapIncomplete = sourceMaps.status === "partial" || sourceMaps.status === "unavailable"; const partial = - accumulator.parseFailures > 0 || + accumulator.failedScripts.length > 0 || + accumulator.recoveredScripts.length > 0 || unavailable.length > 0 || sourceMapIncomplete || omittedScripts !== undefined || @@ -44,9 +45,9 @@ export const buildWebBundleAnalysis = ( }, inferences: accumulator.inferences, unknowns: buildUnknowns({ - sourceScripts, unavailable, - parseFailures: accumulator.parseFailures, + failedScripts: accumulator.failedScripts, + recoveredScripts: accumulator.recoveredScripts, sourceMaps, omittedScripts, omittedSourceMaps, @@ -54,7 +55,7 @@ export const buildWebBundleAnalysis = ( completeness: buildCompleteness({ partial, parsedScripts: accumulator.parsedScripts, - parseFailures: accumulator.parseFailures, + parseFailures: accumulator.failedScripts.length, visitedNodes: accumulator.visitedNodes, }), limitations: bundleLimitations(), @@ -88,9 +89,9 @@ const chunkNode = (script: IncludedScript) => { }; interface UnknownsInput { - readonly sourceScripts: IncludedScript[]; readonly unavailable: string[]; - readonly parseFailures: number; + readonly failedScripts: readonly string[]; + readonly recoveredScripts: readonly string[]; readonly sourceMaps: WebBundleAnalysis["observations"]["source_maps"]; readonly omittedScripts: number | null | undefined; readonly omittedSourceMaps: number | null | undefined; @@ -115,15 +116,23 @@ const buildUnknowns = (input: UnknownsInput): WebBundleAnalysis["unknowns"] => [ affected_script_keys: input.unavailable, }, ]), - ...(input.parseFailures === 0 + ...(input.failedScripts.length === 0 ? [] : [ { dimension: "javascript_ast" as const, reason: "One or more source artifacts could not be parsed", - affected_script_keys: input.sourceScripts.map( - ({ script_key }) => script_key, - ), + affected_script_keys: [...input.failedScripts], + }, + ]), + ...(input.recoveredScripts.length === 0 + ? [] + : [ + { + dimension: "javascript_ast" as const, + reason: + "The parser recovered from errors in one or more source artifacts; findings may be incomplete", + affected_script_keys: [...input.recoveredScripts], }, ]), ...(input.omittedSourceMaps === undefined diff --git a/src/evaluation/AgentEvaluationFixtures.ts b/src/evaluation/AgentEvaluationFixtures.ts index b7ef17efe..b0535a96e 100644 --- a/src/evaluation/AgentEvaluationFixtures.ts +++ b/src/evaluation/AgentEvaluationFixtures.ts @@ -8,7 +8,19 @@ import type { JsonValue } from "../domain/jsonValue.js"; import { javascriptApplicationAnalysisResultSchema } from "../domain/javascript/javascriptApplicationAnalysis.js"; import type { ApplicationNode } from "../domain/javascript/javascriptApplicationGraphSchemas.js"; import { javaScriptExportShapeComparisonResultSchema } from "../domain/javascript/javascriptExportShapeComparisonSchemas.js"; -import type { FixtureClaimExpectation } from "./KnownAnswerEvaluation.js"; +import { + managedArtifactInspectionSchema, + managedMemberInspectionSchema, +} from "../domain/managed/managedArtifact.js"; +import { + managedTokenScopesMatch, + parseManagedInspectionEvidence, +} from "../domain/managed/managedInspectionEvidence.js"; +import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; +import type { + FixtureClaimContext, + FixtureClaimExpectation, +} from "./KnownAnswerEvaluation.js"; /** Source-owned artifact paths used by the configured known-answer benchmarks. */ export interface AgentEvaluationFixtureTargets { @@ -16,6 +28,8 @@ export interface AgentEvaluationFixtureTargets { readonly javascriptSha256: string; readonly javascriptShapeLeft: string; readonly javascriptShapeRight: string; + readonly managed?: string; + readonly managedSha256?: string; } const desktopFixtureFiles = { @@ -277,17 +291,122 @@ const parserClaims = ( ]; }; +const managedMembers = ({ evidence }: FixtureClaimContext) => { + const { result } = parseManagedInspectionEvidence( + evidence, + "inspect_managed_members", + managedMemberInspectionSchema, + ); + if ( + result.coverage.state !== "complete" || + result.metadata.status !== "complete" || + !managedTokenScopesMatch( + result.identity_scope.requires_mvid, + result.module?.mvid ?? null, + ) + ) + return undefined; + return result; +}; + +const managedClaims = ( + targets: AgentEvaluationFixtureTargets, +): readonly FixtureClaimExpectation[] => { + if (targets.managed === undefined || targets.managedSha256 === undefined) + throw new TypeError( + "Managed fixture grading requires the built DLL path and SHA-256", + ); + const source = { + operation: "inspect_managed_members", + authority: "shipped-artifact", + confidence: "observed", + subject: { path: targets.managed, sha256: targets.managedSha256 }, + } satisfies Omit; + return [ + { + id: "public_types", + expectedValue: ["AgentEval.Profile", "AgentEval.Program"], + source: { + ...source, + select: (_value, context) => { + const members = managedMembers(context); + if ( + members === undefined || + members.selection !== null || + Object.hasOwn(context.evidence.parameters, "type_names") + ) + return undefined; + return members.types + .filter((type) => (type.flags & 7) === 1) + .map((type) => type.full_name) + .sort(compareUnicodeCodePoints); + }, + }, + }, + { + id: "entry_point", + expectedValue: { declaring_type: "AgentEval.Program", name: "Main" }, + source: { + ...source, + select: (_value, context) => { + const members = managedMembers(context); + if (members === undefined) return undefined; + const artifacts = context.deliveredEvidence.filter( + (evidence) => + evidence.operation === "inspect_managed_artifact" && + evidence.authority === source.authority && + evidence.confidence === source.confidence && + evidence.subject?.local_path === source.subject.path && + evidence.subject.digest.sha256 === source.subject.sha256, + ); + if (artifacts.length !== 1) return undefined; + const { result: artifact } = parseManagedInspectionEvidence( + artifacts[0], + "inspect_managed_artifact", + managedArtifactInspectionSchema, + ); + if ( + artifact.coverage.state !== "complete" || + artifact.metadata.status !== "complete" || + !managedTokenScopesMatch( + artifact.module?.mvid ?? null, + members.module?.mvid ?? null, + ) || + artifact.pe.cli?.entry_point.kind !== "metadata-token" + ) + return undefined; + const methods = members.methods.filter( + (method) => method.token === artifact.pe.cli?.entry_point.value, + ); + const method = methods.length === 1 ? methods[0] : undefined; + return method === undefined + ? undefined + : { + declaring_type: method.declaring_type, + name: method.name, + }; + }, + }, + }, + ]; +}; + /** Return the closed known-answer rubric, or undefined for routing-only scenarios. */ export const agentFixtureClaims = ( scenarioId: string, targets: AgentEvaluationFixtureTargets, ): readonly FixtureClaimExpectation[] | undefined => { if (scenarioId === "asar") return asarClaims(targets); + if (scenarioId === "managed") return managedClaims(targets); if (scenarioId === "javascript-export-shape") return parserClaims(targets); return undefined; }; const answerValueDescriptions: Readonly> = { + managed: [ + "public_types: string[], listing the exact public top-level type full names in Unicode code-point order from unfiltered member Evidence (omit type_names).", + 'entry_point: {"declaring_type": string, "name": string}, resolving the CLI metadata entry-point token against the member inventory from the same artifact SHA-256 and module MVID. Cite member Evidence; both artifact and member Evidence must be delivered.', + ], asar: [ 'renderer_api: {"key": string, "members": string[], "world": string, "source": string}, describing the exposed bridge API, its members, world, and source file.', 'renderer_ipc: {"channel": string, "operation": string, "source": string}, describing the renderer-side IPC operation and its source file.', diff --git a/src/evaluation/KnownAnswerEvaluation.ts b/src/evaluation/KnownAnswerEvaluation.ts index c6e4af72b..fe71302eb 100644 --- a/src/evaluation/KnownAnswerEvaluation.ts +++ b/src/evaluation/KnownAnswerEvaluation.ts @@ -5,6 +5,12 @@ import { parseEvidence, type Evidence } from "../domain/evidence.js"; import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; import { canonicalJson } from "../domain/comparisonSemantics.js"; +/** Only authenticated, successful, non-conflicting transcript deliveries. */ +export interface FixtureClaimContext { + readonly evidence: Evidence; + readonly deliveredEvidence: readonly Evidence[]; +} + /** One closed fixture assertion and the producer facts that must support it. */ export interface FixtureClaimExpectation { readonly id: string; @@ -20,7 +26,10 @@ export interface FixtureClaimExpectation { readonly path: string; readonly sha256?: string; }[]; - readonly select: (normalizedResult: JsonValue) => JsonValue | undefined; + readonly select: ( + normalizedResult: JsonValue, + context: FixtureClaimContext, + ) => JsonValue | undefined; }; } @@ -430,7 +439,12 @@ const assessClaim = ( try { if ( !sameJson( - expected.source.select(evidence.normalized_result), + expected.source.select(evidence.normalized_result, { + evidence, + deliveredEvidence: [...delivered.records.values()].filter( + (record) => !delivered.conflicts.has(record.evidence_id), + ), + }), expected.expectedValue, ) ) diff --git a/src/ghidra/GhidraDoctor.test.ts b/src/ghidra/GhidraDoctor.test.ts index 69b9acb62..844438408 100644 --- a/src/ghidra/GhidraDoctor.test.ts +++ b/src/ghidra/GhidraDoctor.test.ts @@ -61,7 +61,7 @@ const doctorHost = ( id: "ubuntu", versionId: "24.04", packageFamily: "deb", - supported: true, + hopperSetupSupported: true, }), validTarget: () => Promise.resolve(true), executable: (path) => diff --git a/src/ghidra/GhidraInventoryValues.ts b/src/ghidra/GhidraInventoryValues.ts index 86c17a7fb..85a8b8fbe 100644 --- a/src/ghidra/GhidraInventoryValues.ts +++ b/src/ghidra/GhidraInventoryValues.ts @@ -1,4 +1,9 @@ import { z } from "zod"; +import { + bytePatternFields, + validateBytePattern, + byteSearchResultSchema, +} from "../domain/native/bytePattern.js"; import { nativeLoadImageObservationSchema } from "../domain/native/nativeLoadImage.js"; import { @@ -20,6 +25,7 @@ import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js" export const GHIDRA_INVENTORY_OPERATIONS = [ "inspect_native_load_image", "read_bytes", + "search_bytes", "address_to_file_offset", "address_name", "list_documents", @@ -72,6 +78,16 @@ const searchInput = { }; const inputSchemas = { + search_bytes: z + .strictObject({ + ...bytePatternFields, + document, + start: explicitAddress.optional(), + end: explicitAddress.optional(), + limit: z.number().int().min(1).max(1000).default(100), + cursor: z.string().min(1).optional(), + }) + .superRefine(validateBytePattern), inspect_native_load_image: z.strictObject({}), read_bytes: z.strictObject({ document, @@ -271,6 +287,24 @@ const containingProcedure = z.discriminatedUnion("found", [ ]); const resultSchemas = { + search_bytes: byteSearchResultSchema.superRefine((value, context) => { + const addresses = [ + ...value.matches.flatMap((match) => [ + match.address, + ...(match.procedure === null ? [] : [match.procedure.address]), + ]), + ...[ + ...value.searched.blocks, + ...value.searched.skipped_uninitialized, + ...value.searched.skipped_not_loaded, + ].flatMap((block) => [block.start, block.end]), + ]; + if (addresses.some((value) => !canonicalAddress.safeParse(value).success)) + context.addIssue({ + code: "custom", + message: "Byte search addresses must be canonical", + }); + }), inspect_native_load_image: nativeLoadImageObservationSchema, read_bytes: z .strictObject({ diff --git a/src/ghidra/GhidraProviderCapabilities.ts b/src/ghidra/GhidraProviderCapabilities.ts index 68c3045ce..0714d1ce0 100644 --- a/src/ghidra/GhidraProviderCapabilities.ts +++ b/src/ghidra/GhidraProviderCapabilities.ts @@ -90,6 +90,11 @@ export const limitationsFor = (operation: string): readonly string[] => { ...common, "Bytes reflect Ghidra's initialized memory, including loader fixups. Reads stop at an unmapped or uninitialized byte and report completeness explicitly.", ]; + case "search_bytes": + return [ + ...common, + "Searches initialized loader-adjusted memory independently per block, in provider address order. Bounds and page coverage ends are inclusive. Uninitialized blocks are listed separately. Overlapping matches are included; patterns never cross block boundaries. A cursor resumes the same target, profile and query. Cancellation discards the active search and returns no successful partial page.", + ]; case "address_to_file_offset": return [ ...common, diff --git a/src/hopper/HopperProviderCapabilities.ts b/src/hopper/HopperProviderCapabilities.ts index 00f89fcdb..2dbac4659 100644 --- a/src/hopper/HopperProviderCapabilities.ts +++ b/src/hopper/HopperProviderCapabilities.ts @@ -62,12 +62,16 @@ const MUTATING_OPERATIONS = new Set([ /** Source-owned capabilities without acquiring or probing a Hopper session. */ export const CAPABILITIES: readonly CapabilityDescriptor[] = Object.freeze( - HOPPER_OPERATIONS.map((operation) => + [...HOPPER_OPERATIONS, "search_bytes" as const].map((operation) => Object.freeze({ provider: HOPPER_PROVIDER_IDENTITY, operation, - available: true, - reason: null, + ...(operation === "search_bytes" + ? { + available: false as const, + reason: "Masked byte search has not been verified for Hopper.", + } + : { available: true as const, reason: null }), effects: Object.freeze({ mutatesArtifact: MUTATING_OPERATIONS.has(operation), launchesProcess: true, diff --git a/src/ida/IdaNativeProtocol.test.ts b/src/ida/IdaNativeProtocol.test.ts index 384da0420..466a09ad5 100644 --- a/src/ida/IdaNativeProtocol.test.ts +++ b/src/ida/IdaNativeProtocol.test.ts @@ -279,6 +279,11 @@ describe("native IDA provider observations", () => { ); if (!result.ok) throw result.error; expect(result.value.provider.name).toBe("IDA MCP adapter"); + if (operation === "xrefs") + expect( + result.value.referencesTruncated, + "the native producer's explicit cap flag is forwarded", + ).toBe(false); if (operation === "procedure_assembly") expect(result.value.result).toBe("0x1000: ret"); if (operation === "procedure_pseudo_code") { diff --git a/src/mcpStartupPolicy.ts b/src/mcpStartupPolicy.ts index 280a328c2..dc31114c1 100644 --- a/src/mcpStartupPolicy.ts +++ b/src/mcpStartupPolicy.ts @@ -7,6 +7,8 @@ */ export const MCP_STARTUP_POLICY = { codexStartupTimeoutSeconds: 30, + kimiStartupTimeoutMs: 30_000, + kimiToolTimeoutMs: 120_000, initializeBudgetMs: 15_000, toolsListBudgetMs: 5_000, firstCatalogBudgetMs: 20_000, diff --git a/src/server/EvidenceMcpServer.ts b/src/server/EvidenceMcpServer.ts index 2691ec422..f42fe9de6 100644 --- a/src/server/EvidenceMcpServer.ts +++ b/src/server/EvidenceMcpServer.ts @@ -278,8 +278,13 @@ class ErrorEvidenceTransport implements Transport { // Count the provenance marker exactly as the projected error did, and keep // it on the replacement: the retained original may hold target content. const targetContent = carriesTargetContentMarker(message.result); - // The delivered error omits structuredContent, so only its text counts. - const encoded = encodeToolResult(structured, budget, "text", targetContent); + // The delivered error omits structuredContent, so its text and error flag count. + const encoded = encodeToolResult( + structured, + budget, + "error-text", + targetContent, + ); if (encoded.ok) return message; const operation = tool ?? "mcp_tool_error"; const originalError = analysisErrorProjectionSchema.safeParse( diff --git a/src/server/toolResult.ts b/src/server/toolResult.ts index b503b5677..5d40afb5b 100644 --- a/src/server/toolResult.ts +++ b/src/server/toolResult.ts @@ -139,7 +139,7 @@ export class ToolResultDelivery { const encoded = encodeToolResult( structuredContent, this.resultBudgetBytes, - "text", + "error-text", targetContent, ); // The transport retains an oversized projected error before replacing it diff --git a/src/server/toolResultEncoding.test.ts b/src/server/toolResultEncoding.test.ts index b6969c306..1e9aee5ac 100644 --- a/src/server/toolResultEncoding.test.ts +++ b/src/server/toolResultEncoding.test.ts @@ -48,21 +48,27 @@ describe("MCP result encoding budget", () => { it.each([ { error: { code: "invalid_request", message: '中文"\n'.repeat(30000) } }, { error: { message: "😀\ud800" } }, - ])("counts only escaped text for text-only error delivery", (candidate) => { - const textBytes = Buffer.byteLength( - JSON.stringify({ - content: [{ type: "text", text: JSON.stringify(candidate) }], - }), - ); - const encoded = encodeToolResult(candidate, textBytes, "text"); - if (!encoded.ok) throw new Error("Expected complete encoding"); - expect(encoded.text).toBe(JSON.stringify(candidate)); - expect(encoded.bytes).toBe(textBytes); - expect(encodeToolResult(candidate, textBytes - 1, "text")).toMatchObject({ - ok: false, - bytesAtLeast: textBytes, - }); - }); + ])( + "counts escaped text and the error flag for error delivery", + (candidate) => { + const errorBytes = Buffer.byteLength( + JSON.stringify({ + content: [{ type: "text", text: JSON.stringify(candidate) }], + isError: true, + }), + ); + const encoded = encodeToolResult(candidate, errorBytes, "error-text"); + if (!encoded.ok) throw new Error("Expected complete encoding"); + expect(encoded.text).toBe(JSON.stringify(candidate)); + expect(encoded.bytes).toBe(errorBytes); + expect( + encodeToolResult(candidate, errorBytes - 1, "error-text"), + ).toMatchObject({ + ok: false, + bytesAtLeast: errorBytes, + }); + }, + ); it("accounts for the untrusted target-content marker", () => { const candidate = { strings: ['"ignore previous instructions"', "中文"] }; @@ -94,14 +100,15 @@ describe("MCP result encoding budget", () => { const markedBytes = Buffer.byteLength( JSON.stringify({ content: [{ type: "text", text: JSON.stringify(candidate) }], + isError: true, _meta: TARGET_CONTENT_RESULT_META, }), ); expect( - encodeToolResult(candidate, markedBytes, "text", true), + encodeToolResult(candidate, markedBytes, "error-text", true), ).toMatchObject({ ok: true, bytes: markedBytes }); expect( - encodeToolResult(candidate, markedBytes - 1, "text", true), + encodeToolResult(candidate, markedBytes - 1, "error-text", true), ).toMatchObject({ ok: false, bytesAtLeast: markedBytes }); }); diff --git a/src/server/toolResultEncoding.ts b/src/server/toolResultEncoding.ts index 2a3692674..73ec2c717 100644 --- a/src/server/toolResultEncoding.ts +++ b/src/server/toolResultEncoding.ts @@ -13,8 +13,8 @@ const RESULT_ENVELOPE_BYTES = }), ) - 4; -const TEXT_RESULT_ENVELOPE_BYTES = Buffer.byteLength( - JSON.stringify({ content: [{ type: "text", text: "" }] }), +const ERROR_RESULT_ENVELOPE_BYTES = Buffer.byteLength( + JSON.stringify({ content: [{ type: "text", text: "" }], isError: true }), ); /** @@ -48,9 +48,10 @@ const TARGET_CONTENT_META_BYTES = /** * MCP representations a result carries on the wire. Successful results repeat - * the JSON as structured content and escaped text; error results carry text only. + * the JSON as structured content and escaped text; error results carry text + * only, plus their `isError` flag. */ -export type ToolResultRepresentations = "structured-and-text" | "text"; +export type ToolResultRepresentations = "structured-and-text" | "error-text"; /** Result of encoding the complete repeated MCP representations within a wire budget. */ export type ToolResultEncoding = @@ -72,7 +73,7 @@ export const encodeToolResult = ( const structured = representations === "structured-and-text"; const parts: string[] = []; let bytes = - (structured ? RESULT_ENVELOPE_BYTES : TEXT_RESULT_ENVELOPE_BYTES) + + (structured ? RESULT_ENVELOPE_BYTES : ERROR_RESULT_ENVELOPE_BYTES) + (targetContent ? TARGET_CONTENT_META_BYTES : 0); let codeUnits = bytes; for (const part of bufferedJsonParts(jsonParts(value))) { diff --git a/tests/acceptance/applications/javascriptAnalysisLimits.test.ts b/tests/acceptance/applications/javascriptAnalysisLimits.test.ts new file mode 100644 index 000000000..6f0ba8cef --- /dev/null +++ b/tests/acceptance/applications/javascriptAnalysisLimits.test.ts @@ -0,0 +1,156 @@ +import type { Client } from "@modelcontextprotocol/client"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { z } from "zod"; + +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { parseEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const cases = [ + { + name: "both limits", + args: ["--max-heap-mb", "128", "--analysis-timeout-ms", "30000"], + controls: { max_heap_mb: 128, analysis_timeout_ms: 30000 }, + }, + { + name: "heap limit alone", + args: ["--max-heap-mb", "256"], + controls: { max_heap_mb: 256 }, + }, + { + name: "timeout alone", + args: ["--analysis-timeout-ms", "40000"], + controls: { analysis_timeout_ms: 40000 }, + }, + { + name: "omitted limits", + args: [], + controls: {}, + }, +]; + +cliTest.for(cases)( + "preserves $name in CLI Evidence without adding ambient defaults", + async ({ args, controls }, { cli }) => { + const root = await createTestTempDirectory("rea-js-limit-provenance-"); + await writeFile(join(root, "main.js"), "export const observed = 7;\n"); + const response = await cli.run({ + arguments: [ + "analyze-javascript-application", + root, + ...args, + "--format", + "json", + ], + environment: { NODE_OPTIONS: "--max-old-space-size=512" }, + }); + expect(response.exitCode, response.stdout + response.stderr).toBe(0); + const evidence = parseEvidence(response.json); + expect(evidence.parameters).toEqual({ + format: "auto", + integrity_policy: "fail", + ...controls, + }); + }, +); + +it("preserves MCP analysis limits in retained Evidence and an exported bundle round trip", async () => { + const root = await createTestTempDirectory("rea-js-limit-mcp-"); + await writeFile(join(root, "main.js"), "export const observed = 7;\n"); + const bundleRoot = await createTestTempDirectory("rea-js-limit-bundle-"); + const path = join(bundleRoot, "evidence.json"); + const { client } = await connectLocalToolsMcp({ + NODE_OPTIONS: "--max-old-space-size=512", + }); + const expected = new Map(); + for (const { controls } of cases) { + const response = await client.callTool({ + name: "analyze_javascript_application", + arguments: { input_path: root, ...controls, detail: "summary" }, + }); + expect(response.isError, JSON.stringify(response.content)).not.toBe(true); + const { parent_evidence_id } = z + .object({ + normalized_result: z.object({ parent_evidence_id: z.string() }), + }) + .parse(response.structuredContent).normalized_result; + expected.set(parent_evidence_id, { + format: "auto", + integrity_policy: "fail", + ...controls, + }); + } + expect(expected.size).toBe(cases.length); + const retained = await getBundle(client); + for (const [evidence_id, parameters] of expected) + expect( + retained.records.find((record) => record.evidence_id === evidence_id) + ?.parameters, + ).toEqual(parameters); + const exported = await client.callTool({ + name: "export_evidence_bundle", + arguments: { path }, + }); + expect(exported.isError).not.toBe(true); + expect(parseEvidenceBundle(JSON.parse(await readFile(path, "utf8")))).toEqual( + retained, + ); + const closed = await client.callTool({ + name: "close_binary", + arguments: {}, + }); + expect(closed.isError).not.toBe(true); + expect((await getBundle(client)).records).toEqual([]); + const imported = await client.callTool({ + name: "import_evidence_bundle", + arguments: { path }, + }); + expect(imported.isError, JSON.stringify(imported.content)).not.toBe(true); + expect(await getBundle(client)).toEqual(retained); +}, 60_000); + +it("retains caller limits in partial Evidence after an actual analysis timeout", async () => { + const root = await createTestTempDirectory("rea-js-limit-timeout-"); + await writeFile(join(root, "main.js"), "export const observed = 7;\n"); + const { client } = await connectLocalToolsMcp({ + NODE_OPTIONS: "--max-old-space-size=512", + }); + const response = await client.callTool({ + name: "analyze_javascript_application", + arguments: { + input_path: root, + max_heap_mb: 128, + analysis_timeout_ms: 1, + }, + }); + expect(parseMcpToolError(response)).toMatchObject({ + error: { code: "provider_timeout" }, + }); + const bundle = await getBundle(client); + const partial = bundle.records.find( + (record) => record.operation === "analyze_javascript_application", + ); + expect(partial?.parameters).toEqual({ + format: "auto", + integrity_policy: "fail", + max_heap_mb: 128, + analysis_timeout_ms: 1, + }); + await client.ping(); +}, 30_000); + +async function getBundle(client: Client) { + const response = await client.callTool({ + name: "get_evidence_bundle", + arguments: { detail: "complete" }, + }); + expect(response.isError).not.toBe(true); + return parseEvidenceBundle( + z.object({ result: z.unknown() }).parse(response.structuredContent).result, + ); +} diff --git a/tests/acceptance/applications/javascriptChildProcessArgumentsCli.test.ts b/tests/acceptance/applications/javascriptChildProcessArgumentsCli.test.ts new file mode 100644 index 000000000..84bfca030 --- /dev/null +++ b/tests/acceptance/applications/javascriptChildProcessArgumentsCli.test.ts @@ -0,0 +1,72 @@ +import { join } from "node:path"; + +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js"; +import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js"; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "keeps child argv counts unknown after spread through %s", + async (surface, { cli, processes }) => { + const root = await createTestTempDirectory("rea-child-argv-"); + await writeFixtureFiles(root, { + "entry.mjs": [ + 'import { spawn, execFile, fork } from "node:child_process";', + 'const args = ["a", "b"];', + 'spawn("spread-spawn", [...args]);', + 'execFile("spread-exec", ["prefix", ...args]);', + 'fork("spread-fork", [...args, "suffix"]);', + 'spawn("fixed", ["a", "b"]);', + 'execFile("empty", []);', + ].join("\n"), + "child.cjs": "console.log(process.argv.slice(2).length);", + }); + const args = ["a", "b"]; + const counts: number[] = []; + for (const values of [ + [...args], + ["prefix", ...args], + [...args, "suffix"], + ["a", "b"], + [], + ]) { + const control = await processes.run( + process.execPath, + [join(root, "child.cjs"), ...values], + { timeoutMs: 5_000 }, + ); + expect(control.exitCode, control.stderr).toBe(0); + counts.push(Number(control.stdout)); + } + expect(counts).toEqual([2, 3, 3, 2, 0]); + const graph = (await analyzeJavaScriptDirectory(surface, cli, root)) + .semantic_graph; + const children = graph.nodes.filter( + (node) => node.kind === "child-process", + ); + expect(children).toHaveLength(5); + for (const command of ["spread-spawn", "spread-exec", "spread-fork"]) { + const child = children.find( + (node) => node.properties.command === command, + ); + expect(child?.properties.argv_count, command).toBeNull(); + expect( + graph.relations.filter( + (relation) => + relation.relation === "supplies-argv" && + relation.target_node_id === child?.node_id, + ), + ).toEqual([]); + } + expect( + children.find((node) => node.properties.command === "fixed")?.properties + .argv_count, + ).toBe(2); + expect( + children.find((node) => node.properties.command === "empty")?.properties + .argv_count, + ).toBe(0); + }, +); diff --git a/tests/acceptance/applications/javascriptCommonJsDirectoryLookup.test.ts b/tests/acceptance/applications/javascriptCommonJsDirectoryLookup.test.ts new file mode 100644 index 000000000..da9fcee86 --- /dev/null +++ b/tests/acceptance/applications/javascriptCommonJsDirectoryLookup.test.ts @@ -0,0 +1,188 @@ +import { execFile } from "node:child_process"; +import { createRequire } from "node:module"; +import { join, relative, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +import { expect } from "vitest"; + +import type { JavaScriptApplicationGraph } from "../../../src/domain/javascript/javascriptApplicationGraph.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js"; +import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js"; + +const execute = promisify(execFile); + +const logicalPath = (root: string, path: string): string => + relative(root, path).split(sep).join("/"); + +const requireSources = ( + requests: readonly (readonly [string, string, string | null])[], +): Readonly> => + Object.fromEntries( + [...new Set(requests.map(([source]) => source))].map((source) => [ + source, + requests + .filter(([path]) => path === source) + .map(([, specifier]) => `require(${JSON.stringify(specifier)});`) + .join("\n"), + ]), + ); + +const expectResolution = ( + graph: JavaScriptApplicationGraph, + source: string, + specifier: string, + kind: "require" | "import", + resolvedPath: string | null, +): void => { + const edges = graph.edges.filter( + ({ relation, properties, evidence }) => + relation === "imports" && + properties.specifier === specifier && + evidence.location.available && + evidence.location.value.kind === "source-range" && + evidence.location.value.source === source, + ); + expect(edges).toHaveLength(2); + expect(edges.map(({ properties }) => properties)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: kind === "require" ? "require" : "static-import", + resolved_path: resolvedPath, + }), + expect.objectContaining({ + module_link_kind: kind, + resolved_path: resolvedPath, + resolution_status: resolvedPath === null ? "not-found" : "resolved", + }), + ]), + ); +}; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "preserves CommonJS directory requests before path normalization through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-cjs-directory-lookup-"); + const requests = [ + ["main.cjs", "./dep/.", "dep/index.js"], + ["main.cjs", "./dep/", "dep/index.js"], + ["main.cjs", "./parent/child/..", "parent/index.js"], + ["main.cjs", "./dep", "dep.js"], + ["main.cjs", "./entry/.", "entry/dep.js"], + ["main.cjs", "./missing/.", null], + ["main.cjs", "directory/", "node_modules/directory/index.js"], + ["main.cjs", "directory/.", "node_modules/directory/index.js"], + ["main.cjs", "package/dep/.", "node_modules/package/dep/index.js"], + ["parent/child/main.cjs", ".", "parent/child/index.js"], + ["parent/child/main.cjs", "..", "parent/index.js"], + ["typed.cts", "./typed.js/.", "typed.js/index.js"], + ] as const; + await writeFixtureFiles(root, { + "package.json": '{"type":"commonjs"}', + "dep.js": "module.exports = 'file';", + "dep/index.js": "module.exports = 'directory';", + "entry/package.json": '{"main":"./dep/."}', + "entry/dep.js": "module.exports = 'main file';", + "entry/dep/index.js": "module.exports = 'main directory decoy';", + "missing.js": "module.exports = 'missing directory decoy';", + "parent.js": "module.exports = 'parent file';", + "parent/index.js": "module.exports = 'parent directory';", + "parent/child.js": "module.exports = 'child file';", + "parent/child/index.js": "module.exports = 'child directory';", + "node_modules/directory.js": "module.exports = 'package file';", + "node_modules/directory/index.js": + "module.exports = 'package directory';", + "node_modules/package/dep.js": "module.exports = 'subpath file';", + "node_modules/package/dep/index.js": + "module.exports = 'subpath directory';", + "typed.ts": "module.exports = 'emitted source decoy';", + "typed.js/index.js": "module.exports = 'typed request directory';", + ...requireSources(requests), + }); + for (const [source, specifier, expected] of requests) { + const require = createRequire(join(root, source)); + if (expected === null) + expect(() => require.resolve(specifier)).toThrow( + expect.objectContaining({ code: "MODULE_NOT_FOUND" }), + ); + else expect(logicalPath(root, require.resolve(specifier))).toBe(expected); + } + const { graph } = await analyzeJavaScriptDirectory(surface, cli, root); + for (const [source, specifier, expected] of requests) + expectResolution(graph, source, specifier, "require", expected); + expect(graph.coverage.status).toBe("complete"); + }, +); + +cliTest.for(["CLI", "stdio MCP"] as const)( + "preserves loader-specific node_modules ancestors through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-cjs-ancestor-lookup-"); + const requests = [ + [ + "node_modules/client/main.cjs", + "shared", + "node_modules/shared/index.js", + ], + [ + "node_modules/client/main.cjs", + "@scope/shared", + "node_modules/@scope/shared/index.js", + ], + [ + "node_modules/client/main.cjs", + "private", + "node_modules/client/node_modules/private/index.js", + ], + [ + "node_modules/@scope/client/main.cjs", + "shared", + "node_modules/shared/index.js", + ], + [ + "node_modules/parent/node_modules/client/main.cjs", + "shared", + "node_modules/parent/node_modules/shared/index.js", + ], + ] as const; + const esmSource = "node_modules/client/main.mjs"; + const esmTarget = "node_modules/node_modules/shared/index.js"; + await writeFixtureFiles(root, { + "package.json": '{"type":"commonjs"}', + "node_modules/shared/index.js": "module.exports = 'outer dependency';", + "node_modules/@scope/shared/index.js": + "module.exports = 'scoped dependency';", + "node_modules/node_modules/shared/index.js": + "module.exports = 'ESM dependency';", + "node_modules/node_modules/@scope/shared/index.js": + "module.exports = 'scoped decoy';", + "node_modules/client/node_modules/private/index.js": + "module.exports = 'private dependency';", + "node_modules/parent/node_modules/shared/index.js": + "module.exports = 'nested dependency';", + "node_modules/parent/node_modules/node_modules/shared/index.js": + "module.exports = 'nested decoy';", + [esmSource]: + 'import "shared"; console.log(JSON.stringify(import.meta.resolve("shared")));', + ...requireSources(requests), + }); + for (const [source, specifier, expected] of requests) + expect( + logicalPath(root, createRequire(join(root, source)).resolve(specifier)), + ).toBe(expected); + const control = await execute(process.execPath, [join(root, esmSource)], { + cwd: root, + timeout: 5_000, + }); + expect( + logicalPath(root, fileURLToPath(JSON.parse(control.stdout) as string)), + ).toBe(esmTarget); + const { graph } = await analyzeJavaScriptDirectory(surface, cli, root); + for (const [source, specifier, expected] of requests) + expectResolution(graph, source, specifier, "require", expected); + expectResolution(graph, esmSource, "shared", "import", esmTarget); + expect(graph.coverage.status).toBe("complete"); + }, +); diff --git a/tests/boundary/cli/clientProfileLifecycle.test.ts b/tests/boundary/cli/clientProfileLifecycle.test.ts index 4fd566fd7..8a71b669d 100644 --- a/tests/boundary/cli/clientProfileLifecycle.test.ts +++ b/tests/boundary/cli/clientProfileLifecycle.test.ts @@ -287,9 +287,12 @@ cliTest("uninstall names each retained skill directory", async ({ cli }) => { }; const skill = report.items?.find(({ name }) => name === "skill"); expect(skill?.status).toBe("retained"); - for (const directory of [shared, claude]) { + for (const [client, directory] of [ + ["shared", shared], + ["claude_code", claude], + ]) { expect(skill?.detail).toContain( - `${directory}: retained (Skill manifest is not REA-owned.)`, + `${client} (${directory}): retained (Skill manifest is not REA-owned.)`, ); expect(await readFile(join(directory, "SKILL.md"), "utf8")).toBe( "---\nname: unrelated\n---\n", diff --git a/tests/boundary/filesystem/clientLifecycle.test.ts b/tests/boundary/filesystem/clientLifecycle.test.ts index 63ca0667a..0e4054ae6 100644 --- a/tests/boundary/filesystem/clientLifecycle.test.ts +++ b/tests/boundary/filesystem/clientLifecycle.test.ts @@ -64,6 +64,7 @@ describe("client configuration filesystem lifecycle", () => { "copilot_cli", "commandcode", "qwen_code", + "kimi", "vscode", "trae", "grok_build", @@ -1895,14 +1896,11 @@ describe("client configuration filesystem failure reporting", () => { remove: () => Promise.reject(new Error("SECRET removal failure")), }), ); - expect(result.items).toContainEqual( - expect.objectContaining({ - name: "skill", - status: "failed", - detail: expect.stringContaining( - `Managed file could not be removed: ${join(skillRoot, "SKILL.md")}`, - ), - }), + const skillItem = result.items.find(({ name }) => name === "skill"); + expect(skillItem?.status).toBe("failed"); + expect(skillItem?.detail).toContain(`shared (${skillRoot}): failed (`); + expect(skillItem?.detail).toContain( + `Managed file could not be removed: ${join(skillRoot, "SKILL.md")}`, ); expect(JSON.stringify(result)).not.toContain("SECRET"); expect(await readFile(join(skillRoot, "SKILL.md"), "utf8")).toBe( @@ -1944,6 +1942,42 @@ describe("client configuration filesystem failure reporting", () => { }); }); +describe("skill root client identifier labels", () => { + it("labels each retained or removed skill root by its client identifier", async () => { + const home = await createTestTempDirectory("rea-uninstall-skill-labels-"); + roots.push(home); + const customClaudeDirectory = join(home, "claude-config"); + const sharedRoot = join(home, ".agents/skills/reverse-engineer-anything"); + const claudeRoot = join( + customClaudeDirectory, + "skills/reverse-engineer-anything", + ); + await mkdir(sharedRoot, { recursive: true }); + await mkdir(claudeRoot, { recursive: true }); + await writeFile( + join(sharedRoot, "SKILL.md"), + "---\nname: another-skill\n---\n# Not REA-owned\n", + ); + await writeFile(join(claudeRoot, "SKILL.md"), ownedSkillManifest); + + const skillItem = await systemUninstallHost(home, testFileSystem, { + CLAUDE_CONFIG_DIR: customClaudeDirectory, + }).removeSkill(); + + expect(skillItem.name).toBe("skill"); + expect(skillItem.detail).toContain(`shared (${sharedRoot}): retained (`); + expect(skillItem.detail).toContain( + `claude_code (${claudeRoot}): removed (`, + ); + await expect( + readFile(join(claudeRoot, "SKILL.md"), "utf8"), + ).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(join(sharedRoot, "SKILL.md"), "utf8")).toContain( + "another-skill", + ); + }); +}); + const testFileSystem: UninstallFileSystem = { readText: (path) => readFile(path, "utf8"), copy: (source, destination) => diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index 21ec633c5..543248e63 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -405,12 +405,16 @@ describe("keyed archive hierarchy coverage", () => { const resources = join(bundle, "Contents", "Resources"); await mkdir(resources, { recursive: true }); const archive = join(resources, "Foundation.nib"); - await compile("/usr/bin/xcrun", [ - "swift", - "tests/conformance/native/keyed-archive-hierarchy.swift", - archive, - "40", - ]); + await compile( + "/usr/bin/xcrun", + [ + "swift", + "tests/conformance/native/keyed-archive-hierarchy.swift", + archive, + "40", + ], + { timeout: 90_000 }, + ); const analysis = await analyzeInterfaceBuilderBundle({ bundlePath: bundle, @@ -431,6 +435,7 @@ describe("keyed archive hierarchy coverage", () => { ), ).toHaveLength(41); }, + 120_000, ); }); diff --git a/tests/boundary/filesystem/kimiClientConfiguration.test.ts b/tests/boundary/filesystem/kimiClientConfiguration.test.ts new file mode 100644 index 000000000..e0580031a --- /dev/null +++ b/tests/boundary/filesystem/kimiClientConfiguration.test.ts @@ -0,0 +1,384 @@ +import { access, 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 { setupRegistrationCommand } from "../../../src/application/SetupHost.js"; +import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js"; +import { + clientSkillDirectories, + supportedClients, +} from "../../../src/application/SupportedClients.js"; +import { systemUninstallHost } from "../../../src/application/Uninstall.js"; +import { MCP_STARTUP_POLICY } from "../../../src/mcpStartupPolicy.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { + clearClientLocationEnvironment, + NPX_REGISTRATION_COMMAND, +} from "../../fixtures/clientEnvironment.js"; + +const command = NPX_REGISTRATION_COMMAND; + +beforeEach(clearClientLocationEnvironment); +afterEach(() => vi.unstubAllEnvs()); + +const kimiPath = ( + home: string, + platform: NodeJS.Platform, + env: Parameters[2] = {}, +) => supportedClients(home, platform, env).find(({ name }) => name === "kimi"); + +const exists = async (path: string): Promise => { + try { + await access(path); + return true; + } catch { + return false; + } +}; + +const expectedRegistration = (launcher: readonly string[]) => ({ + command: launcher[0], + args: launcher.slice(1), + startupTimeoutMs: MCP_STARTUP_POLICY.kimiStartupTimeoutMs, + toolTimeoutMs: MCP_STARTUP_POLICY.kimiToolTimeoutMs, +}); + +describe("Kimi Code configuration paths and skills", () => { + it.each([ + [undefined, join("/home/a/.kimi-code")], + ["", join("/home/a/.kimi-code")], + [join("/tmp", "isolated-kimi"), join("/tmp", "isolated-kimi")], + ])("resolves KIMI_CODE_HOME for %j", (override, directory) => { + const client = kimiPath( + "/home/a", + "linux", + override === undefined ? {} : { KIMI_CODE_HOME: override }, + ); + expect(client).toEqual({ + name: "kimi", + displayName: "Kimi Code", + configPath: join(directory, "mcp.json"), + markerPath: directory, + format: "kimi", + }); + }); + + it("uses the existing shared skill destination without a separate skill root", () => { + expect(clientSkillDirectories("/home/a", ["kimi"])).toEqual([ + { client: "shared", directory: "/home/a/.agents/skills" }, + ]); + }); +}); + +describe("Kimi Code registration lifecycle", () => { + const kimiClient = async () => { + const home = await createTestTempDirectory("rea-kimi-"); + const client = kimiPath(home, process.platform, {}); + if (client?.markerPath === undefined) + throw new Error("missing Kimi Code client"); + await mkdir(client.markerPath, { recursive: true }); + return { home, client }; + }; + + it("preserves the caller's enabledTools selection during refresh", async () => { + const { client } = await kimiClient(); + const enabledTools = ["binary_session", "analyze_javascript_application"]; + await writeFile( + client.configPath, + JSON.stringify({ + mcpServers: { + rea: { command: "old-rea", args: ["mcp"], enabledTools }, + }, + }), + ); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + expect( + JSON.parse(await readFile(client.configPath, "utf8")).mcpServers.rea, + ).toEqual({ + ...expectedRegistration(command), + enabledTools, + }); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("unchanged"); + }); + + it("registers without type, preserves unrelated settings, backs up, and is idempotent", async () => { + const { home, client } = await kimiClient(); + const original = `{ + "customSetting": { "enabled": true }, + "mcpServers": { + "other": { "command": "other-server", "startupTimeoutMs": 5000 } + } +} +`; + await writeFile(client.configPath, original); + + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "configured", + backupPath: `${client.configPath}.rea.backup`, + }); + const text = await readFile(client.configPath, "utf8"); + expect(JSON.parse(text)).toEqual({ + customSetting: { enabled: true }, + mcpServers: { + other: { command: "other-server", startupTimeoutMs: 5000 }, + rea: expectedRegistration(command), + }, + }); + expect(text).not.toContain('"type"'); + expect(text).not.toContain('"transport"'); + expect(await readFile(`${client.configPath}.rea.backup`, "utf8")).toBe( + original, + ); + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "unchanged", + }); + expect(await readFile(client.configPath, "utf8")).toBe(text); + expect( + ( + await readClientRegistrationStatuses(home, undefined, { + environment: {}, + }) + ).find(({ client: name }) => name === "kimi"), + ).toMatchObject({ state: "aligned", command }); + + expect((await systemUninstallHost(home).removeClient(client)).status).toBe( + "removed", + ); + expect(JSON.parse(await readFile(client.configPath, "utf8"))).toEqual({ + customSetting: { enabled: true }, + mcpServers: { + other: { command: "other-server", startupTimeoutMs: 5000 }, + }, + }); + }); +}); + +describe("Kimi Code package launchers", () => { + const kimiClient = async () => { + const home = await createTestTempDirectory("rea-kimi-launcher-"); + const client = kimiPath(home, process.platform, {}); + if (client?.markerPath === undefined) + throw new Error("missing Kimi Code client"); + await mkdir(client.markerPath, { recursive: true }); + return { home, client } as const; + }; + + it.each([ + ["npx", "linux"], + ["bunx", "linux"], + ["npx", "win32"], + ["bunx", "win32"], + ] as const)( + "keeps the %s launcher aligned on %s", + async (runner, platform) => { + const { home, client } = await kimiClient(); + const launcher = setupRegistrationCommand(platform, runner); + expect( + (await configureClientConfiguration(client, {}, launcher)).status, + ).toBe("configured"); + expect( + JSON.parse(await readFile(client.configPath, "utf8")).mcpServers.rea, + ).toEqual(expectedRegistration(launcher)); + expect( + ( + await readClientRegistrationStatuses(home, undefined, { + platform, + environment: {}, + }) + ).find(({ client: name }) => name === "kimi"), + ).toMatchObject({ state: "aligned", command: launcher }); + expect(await configureClientConfiguration(client, {}, launcher)).toEqual({ + status: "unchanged", + }); + }, + ); +}); + +describe("Kimi Code transport validation", () => { + const kimiClient = async () => { + const home = await createTestTempDirectory("rea-kimi-validation-"); + const client = kimiPath(home, process.platform, {}); + if (client?.markerPath === undefined) + throw new Error("missing Kimi Code client"); + await mkdir(client.markerPath, { recursive: true }); + return { home, client }; + }; + + it("creates a registration in a zero-byte file and accepts whitespace-only files", async () => { + const { home, client } = await kimiClient(); + for (const original of ["", " \n\t"]) { + await writeFile(client.configPath, original); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + expect( + JSON.parse(await readFile(client.configPath, "utf8")).mcpServers.rea, + ).toEqual(expectedRegistration(command)); + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "unchanged", + }); + expect( + (await systemUninstallHost(home).removeClient(client)).status, + ).toBe("removed"); + } + }); + + it.each([ + '{"mcpServers": {', + '{/* comment */ "mcpServers": {}}', + '{"mcpServers": {},}', + '\uFEFF{"mcpServers": {}}', + ])("rejects invalid strict JSON %j without writes", async (original) => { + const { client } = await kimiClient(); + await writeFile(client.configPath, original); + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "failed", + reason: "readback", + }); + expect(await readFile(client.configPath, "utf8")).toBe(original); + expect(await exists(`${client.configPath}.rea.backup`)).toBe(false); + }); + + it("accepts explicit stdio transport without causing repeated rewrites", async () => { + const { home, client } = await kimiClient(); + await writeFile( + client.configPath, + JSON.stringify({ + mcpServers: { + rea: { + transport: "stdio", + ...expectedRegistration(command), + }, + }, + }), + ); + expect( + ( + await readClientRegistrationStatuses(home, undefined, { + environment: {}, + }) + ).find(({ client: name }) => name === "kimi"), + ).toMatchObject({ state: "aligned", command }); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "unchanged", + }); + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "unchanged", + }); + expect( + ( + await readClientRegistrationStatuses(home, undefined, { + environment: {}, + }) + ).find(({ client: name }) => name === "kimi"), + ).toMatchObject({ state: "aligned", command }); + }); + + it.each(["sse"])( + "rejects explicit transport %j instead of silently accepting another protocol", + async (transport) => { + const { home, client } = await kimiClient(); + await writeFile( + client.configPath, + JSON.stringify({ + mcpServers: { + rea: { + transport, + ...expectedRegistration(command), + }, + }, + }), + ); + expect( + ( + await readClientRegistrationStatuses(home, undefined, { + environment: {}, + }) + ).find(({ client: name }) => name === "kimi"), + ).toMatchObject({ state: "invalid", command: [] }); + }, + ); +}); + +describe("Kimi Code timeout validation", () => { + const kimiClient = async () => { + const home = await createTestTempDirectory("rea-kimi-timeout-"); + const client = kimiPath(home, process.platform, {}); + if (client?.markerPath === undefined) + throw new Error("missing Kimi Code client"); + await mkdir(client.markerPath, { recursive: true }); + return { home, client } as const; + }; + + it.each([ + ["startupTimeoutMs", 1], + ["toolTimeoutMs", 1], + ["startupTimeoutMs", undefined], + ["toolTimeoutMs", undefined], + ] as const)("reports stale when %s is %j", async (field, value) => { + const { home, client } = await kimiClient(); + const registration = { + ...expectedRegistration(command), + [field]: value, + }; + await writeFile( + client.configPath, + JSON.stringify({ mcpServers: { rea: registration } }), + ); + expect( + ( + await readClientRegistrationStatuses(home, undefined, { + environment: {}, + }) + ).find(({ client: name }) => name === "kimi"), + ).toMatchObject({ state: "stale" }); + }); + + it("uses a direct installed REA command without requiring a package runner", async () => { + const { home, client } = await kimiClient(); + const entry = join(home, "scripts", "rea.mjs"); + const launcher = [process.execPath, entry, "mcp"]; + expect( + (await configureClientConfiguration(client, {}, launcher)).status, + ).toBe("configured"); + expect( + ( + await readClientRegistrationStatuses(home, entry, { + environment: {}, + }) + ).find(({ client: name }) => name === "kimi"), + ).toMatchObject({ state: "aligned", command: launcher }); + }); + + it("leaves a foreign rea-key registration out of aligned state", async () => { + const { home, client } = await kimiClient(); + await writeFile( + client.configPath, + JSON.stringify({ + mcpServers: { + rea: { + ...expectedRegistration(command), + command: "another-package", + args: ["mcp"], + }, + }, + }), + ); + expect( + ( + await readClientRegistrationStatuses(home, undefined, { + environment: {}, + }) + ).find(({ client: name }) => name === "kimi"), + ).toMatchObject({ state: "stale" }); + }); +}); diff --git a/tests/boundary/filesystem/setupSkillCleanupOwnership.test.ts b/tests/boundary/filesystem/setupSkillCleanupOwnership.test.ts index dfb472972..4ca9ea407 100644 --- a/tests/boundary/filesystem/setupSkillCleanupOwnership.test.ts +++ b/tests/boundary/filesystem/setupSkillCleanupOwnership.test.ts @@ -70,7 +70,7 @@ const failFirstCloses = (path: string) => { describe("skill regular-file cleanup ownership", () => { it("retains a failed setup skill read until setup host disposal", async () => { const home = await createTestTempDirectory("rea-setup-skill-close-"); - const [destination] = skillDestinations(home, []); + const [destination] = skillDestinations(home, [], {}, "linux"); if (destination === undefined) throw new Error("Shared skill root missing"); const manifest = join(destination.path, "SKILL.md"); await mkdir(dirname(manifest), { recursive: true }); diff --git a/tests/boundary/mcp/comparisonUnknownsMcp.test.ts b/tests/boundary/mcp/comparisonUnknownsMcp.test.ts index bf950f83b..f872d2f15 100644 --- a/tests/boundary/mcp/comparisonUnknownsMcp.test.ts +++ b/tests/boundary/mcp/comparisonUnknownsMcp.test.ts @@ -49,6 +49,9 @@ const observeAgain = ( result, confidence: source.confidence, authority: source.authority, + ...(source.relationship_coverage === null + ? {} + : { relationshipCoverage: source.relationship_coverage }), }, ); diff --git a/tests/boundary/mcp/ghidraInventoryMcp.test.ts b/tests/boundary/mcp/ghidraInventoryMcp.test.ts index 5573c0cbe..ecd331816 100644 --- a/tests/boundary/mcp/ghidraInventoryMcp.test.ts +++ b/tests/boundary/mcp/ghidraInventoryMcp.test.ts @@ -5,9 +5,277 @@ import { join } from "node:path"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { nativeAotPeFixture } from "../../fixtures/nativeaotPe.js"; import { jsonValueSchema } from "../../../src/domain/jsonValue.js"; -import { ok } from "../../../src/domain/result.js"; +import { err, ok } from "../../../src/domain/result.js"; import { ghidraFunctionIdentity } from "../../../src/domain/ghidraValues.fixture.js"; -import { connectGhidraMcp, sessionEvidence } from "./ghidraMcpHarness.js"; +import { + connectGhidraMcp, + ghidraInstallationHost, + sessionEvidence, +} from "./ghidraMcpHarness.js"; +import { spawn } from "node:child_process"; +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { GhidraClient } from "../../../src/ghidra/GhidraClient.js"; +import { GhidraProvider } from "../../../src/ghidra/GhidraProvider.js"; +import { parseConfig } from "../../../src/config/parseConfig.js"; +import { silentLogger } from "../../../src/logger.js"; +import { composeBinarySession } from "../../../src/application/binary/BinarySessionComposition.js"; +import { AnalysisProviderRegistry } from "../../../src/application/binary/AnalysisProviderRegistry.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { waitForExit } from "../../support/process/processFixture.js"; +import { GhidraSessionError } from "../../../src/ghidra/GhidraSessionError.js"; + +it("retains byte-search coverage and rejects malformed pages before creating Evidence", async () => { + const page = { + matches: [ + { + address: "0x401000", + segment: ".text", + file_offset: null, + procedure: null, + }, + ], + searched: { + blocks: [ + { segment: ".text", start: "0x401000", end: "0x40100f", bytes: 16 }, + ], + bytes: 16, + skipped_uninitialized: [ + { segment: ".bss", start: "0x402000", end: "0x40200f", bytes: 16 }, + ], + skipped_not_loaded: [ + { segment: ".symtab", start: "0x0", end: "0x17f", bytes: 384 }, + ], + }, + complete: false, + next_cursor: "continuation", + }; + let output = jsonValueSchema.parse(page); + const harness = await connectGhidraMcp("byte-search-projection", () => + Promise.resolve(ok(output)), + ); + try { + const first = await harness.mcp.callTool({ + name: "search_bytes", + arguments: { pattern: "48 ??", limit: 1 }, + }); + expect(first.isError).not.toBe(true); + expect( + sessionEvidence(harness.session, first.structuredContent) + .normalized_result, + ).toEqual(page); + output = jsonValueSchema.parse({ + ...page, + matches: [], + complete: true, + next_cursor: null, + }); + const last = await harness.mcp.callTool({ + name: "search_bytes", + arguments: { pattern: "48 ??", cursor: "continuation" }, + }); + expect(last.isError).not.toBe(true); + expect( + sessionEvidence(harness.session, last.structuredContent) + .normalized_result, + ).toEqual(output); + output = jsonValueSchema.parse({ ...page, complete: true }); + const malformed = await harness.mcp.callTool({ + name: "search_bytes", + arguments: { pattern: "49 ??" }, + }); + expect(parseMcpToolError(malformed)).toMatchObject({ + error: { code: "unreadable_output" }, + }); + for (const block of [ + { segment: ".text", start: "0x1000", end: "0x1009", bytes: 1 }, + { segment: ".text", start: "0x1009", end: "0x1000", bytes: 1 }, + { segment: ".text", start: "overlay:0x1000", end: "0x1000", bytes: 1 }, + { segment: ".text", start: "invalid", end: "0x1000", bytes: 1 }, + ]) { + output = jsonValueSchema.parse({ + ...page, + searched: { ...page.searched, blocks: [block], bytes: block.bytes }, + }); + const invalidCoverage = await harness.mcp.callTool({ + name: "search_bytes", + arguments: { pattern: "48 ??" }, + }); + expect(parseMcpToolError(invalidCoverage)).toMatchObject({ + error: { code: "unreadable_output" }, + }); + expect(invalidCoverage.structuredContent).toBeUndefined(); + } + const calls = harness.calls.length; + for (const args of [{ pattern: "??" }, { pattern: "48", mask: "ffff" }]) { + const invalid = await harness.mcp.callTool({ + name: "search_bytes", + arguments: args, + }); + expect(invalid.isError).toBe(true); + expect(JSON.stringify(invalid.content)).toContain( + "Input validation error", + ); + } + expect(harness.calls).toHaveLength(calls); + } finally { + await harness.close(); + } +}); + +it("projects cancelled byte searches as typed failures without successful partial Evidence", async () => { + const harness = await connectGhidraMcp("byte-search-cancelled", () => + Promise.resolve( + err(new GhidraSessionError("cancelled", "Search cancelled")), + ), + ); + try { + const reply = await harness.mcp.callTool({ + name: "search_bytes", + arguments: { pattern: "48 8b" }, + }); + expect(reply.isError).toBe(true); + expect(parseMcpToolError(reply)).toMatchObject({ + error: { code: "cancelled" }, + }); + expect(reply.structuredContent).toBeUndefined(); + } finally { + await harness.close(); + } +}); + +it("searches bytes through MCP and the socket bridge with pagination and skipped coverage", async () => { + const config = parseConfig({ GHIDRA_INSTALL_DIR: "/fixture/ghidra" }); + if (!config.ok) throw config.error; + const provider = new GhidraProvider( + config.value, + silentLogger, + {}, + ghidraInstallationHost(), + (options) => + new GhidraClient({ + ...options, + platform: process.platform, + startupTimeoutMs: 5000, + launcher: { + launch: async (launch) => { + const projectRoot = join(launch.runtimeRoot, "project"); + await mkdir(projectRoot); + const child = spawn( + process.execPath, + [ + fileURLToPath( + new URL("../../fixtures/fakeGhidra.mjs", import.meta.url), + ), + ], + { + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + REA_GHIDRA_FIXTURE_CONFIG: JSON.stringify({ + endpointPath: launch.endpointPath, + token: launch.token, + runId: launch.runId, + providerVersion: launch.providerVersion, + profileDigest: launch.profileDigest, + targetSha256: launch.targetSha256, + transport: launch.transport, + }), + }, + }, + ); + return ok({ + process: child, + ownsProcessLifetime: true as const, + projectRoot, + ghidraLogPath: join(launch.runtimeRoot, "ghidra.log"), + scriptLogPath: join(launch.runtimeRoot, "script.log"), + cleanup: async () => { + const signaled = + child.exitCode === null && child.signalCode === null; + if (signaled) child.kill("SIGTERM"); + return (await waitForExit(child, 2000)) + ? { cleaned: true as const, signaled } + : { cleaned: false as const, reason: "Fixture did not exit" }; + }, + }); + }, + }, + }), + ); + const session = composeBinarySession( + new AnalysisProviderRegistry([provider]), + ); + const server = createServer( + { kind: "session", session }, + { logger: silentLogger }, + ); + const client = new Client({ name: "byte-search", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const opened = await client.callTool({ + name: "open_binary", + arguments: { path: process.execPath }, + }); + expect(opened.isError).not.toBe(true); + const first = await client.callTool({ + name: "search_bytes", + arguments: { pattern: "48 8b ?? ?? 89", limit: 1 }, + }); + expect(first.isError, JSON.stringify(first)).not.toBe(true); + const firstEvidence = sessionEvidence(session, first.structuredContent); + expect(firstEvidence.normalized_result).toMatchObject({ + matches: [ + { + address: "0x1000", + segment: ".text", + file_offset: 0, + procedure: { name: "fixture_main" }, + }, + ], + searched: { + bytes: 10, + skipped_uninitialized: [{ segment: ".bss", bytes: 16 }], + }, + complete: false, + next_cursor: "fixture-second-page", + }); + const second = await client.callTool({ + name: "search_bytes", + arguments: { + pattern: "48 8b ?? ?? 89", + limit: 1, + cursor: "fixture-second-page", + }, + }); + expect(second.isError).not.toBe(true); + expect( + sessionEvidence(session, second.structuredContent).normalized_result, + ).toMatchObject({ + matches: [{ address: "0x1005", file_offset: 5 }], + complete: true, + next_cursor: null, + }); + const invalid = await client.callTool({ + name: "search_bytes", + arguments: { pattern: "??" }, + }); + expect(invalid.isError).toBe(true); + expect(JSON.stringify(invalid.content)).toContain( + "Pattern must constrain at least one bit", + ); + } finally { + await client.close(); + await server.close(); + const closed = await session.close(); + if (!closed.ok) throw closed.error; + } +}); it("retains missing PDB coverage in empty PE name-search Evidence and snapshot replay", async () => { const root = await createTestTempDirectory("rea-pdb-evidence-"); diff --git a/tests/boundary/mcp/ghidraMcpHarness.ts b/tests/boundary/mcp/ghidraMcpHarness.ts index 2ff9d0790..fea27b037 100644 --- a/tests/boundary/mcp/ghidraMcpHarness.ts +++ b/tests/boundary/mcp/ghidraMcpHarness.ts @@ -54,7 +54,7 @@ export const connectGhidraMcp = async ( config.value, silentLogger, {}, - installationHost(), + ghidraInstallationHost(), factory, ); const session = composeBinarySession( @@ -100,7 +100,7 @@ export const sessionEvidence = (session: BinarySession, value: unknown) => { return evidence; }; -const installationHost = (): GhidraInstallationHost => ({ +export const ghidraInstallationHost = (): GhidraInstallationHost => ({ platform: "linux", architecture: "x64", readText: () => "application.version=12.1.4\napplication.java.min=21\n", diff --git a/tests/boundary/mcp/officialArguments.test.ts b/tests/boundary/mcp/officialArguments.test.ts index ff0284ac0..a5fe0f2b1 100644 --- a/tests/boundary/mcp/officialArguments.test.ts +++ b/tests/boundary/mcp/officialArguments.test.ts @@ -43,7 +43,23 @@ describe("official operation arguments", () => { const execute: AnalysisOperationPort["execute"] = (name, parameters) => { calls.push({ name, parameters }); return Promise.resolve( - observed(name === "search_strings" ? [] : "int main() {}"), + observed( + name === "search_bytes" + ? { + matches: [], + searched: { + blocks: [], + bytes: 0, + skipped_uninitialized: [], + skipped_not_loaded: [], + }, + complete: true, + next_cursor: null, + } + : name === "search_strings" + ? [] + : "int main() {}", + ), ); }; const createSession = () => @@ -59,6 +75,7 @@ describe("official operation arguments", () => { const client = await connect({ execute }); for (const [name, mcpParameters, cliParameters] of [ ["procedure_pseudo_code", { procedure: "main" }, { procedure: "main" }], + ["search_bytes", { pattern: "48 ??" }, { pattern: "48 ??", limit: 100 }], [ "search_strings", { pattern: "entry" }, @@ -93,6 +110,11 @@ describe("official operation arguments", () => { ["list_procedures", { document: null }], ["procedure_pseudo_code", { procedure: "main", document: 3 }], ["search_strings", { pattern: "entry", case_sensitive: null }], + ["search_bytes", { pattern: "??" }], + ["search_bytes", { pattern: "48 8B", mask: "ff" }], + ["search_bytes", { pattern: "4?" }], + ["search_bytes", { pattern: "48", limit: 1001 }], + ["search_bytes", { pattern: "48", cursor: null }], ] as const) { const result = await client.callTool({ name, arguments: parameters }); expect(result.isError).toBe(true); diff --git a/tests/boundary/mcp/officialTools.test.ts b/tests/boundary/mcp/officialTools.test.ts index 70fbf8587..e28639dd9 100644 --- a/tests/boundary/mcp/officialTools.test.ts +++ b/tests/boundary/mcp/officialTools.test.ts @@ -10,6 +10,12 @@ import { err } from "../../../src/domain/result.js"; import { observed as ok } from "../../fixtures/analysisExecution.js"; import type { JsonValue } from "../../../src/domain/jsonValue.js"; import { createServer } from "../../../src/server/createServer.js"; +import { HopperProvider } from "../../../src/hopper/HopperProvider.js"; +import { parseConfig } from "../../../src/config/parseConfig.js"; +import { silentLogger } from "../../../src/logger.js"; +import { composeBinarySession } from "../../../src/application/binary/BinarySessionComposition.js"; +import { AnalysisProviderRegistry } from "../../../src/application/binary/AnalysisProviderRegistry.js"; +import { createBinarySessionTargets } from "../../fixtures/binarySession.js"; interface Invocation { readonly name: string; @@ -18,6 +24,67 @@ interface Invocation { const resources: Array<{ close(): Promise }> = []; +it("returns typed Hopper byte-search unavailability without launching the provider", async () => { + const [target] = await createBinarySessionTargets(); + const config = parseConfig({ HOPPER_LAUNCHER_PATH: process.execPath }); + if (!config.ok) throw config.error; + const provider = new HopperProvider(config.value, silentLogger, {}, "linux"); + const session = composeBinarySession( + new AnalysisProviderRegistry([provider]), + ); + const server = createServer( + { kind: "session", session }, + { logger: silentLogger }, + ); + const client = new Client({ + name: "hopper-byte-search-unavailable", + version: "1", + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const opened = await client.callTool({ + name: "open_binary", + arguments: { path: target }, + }); + expect(opened.isError).not.toBe(true); + const result = await client.callTool({ + name: "search_bytes", + arguments: { pattern: "48 8b" }, + }); + expect(result.isError).toBe(true); + expect(parseMcpToolError(result)).toMatchObject({ + error: { + code: "capability_unavailable", + details: { provider_id: "hopper", operation: "search_bytes" }, + }, + }); + } finally { + await client.close(); + await server.close(); + const closed = await session.close(); + if (!closed.ok) throw closed.error; + } +}); + +it("advertises strict masked-byte arguments and bounded pages", () => { + const contract = OFFICIAL_TOOL_CONTRACTS.find( + ({ name }) => name === "search_bytes", + ); + expect( + contract?.inputSchema.parse({ pattern: "48 8B ?? ?? 89" }), + ).toMatchObject({ limit: 100 }); + expect( + contract?.inputSchema.safeParse({ pattern: "48", ignored: true }).success, + ).toBe(false); + expect( + contract?.inputSchema.safeParse({ pattern: "48", mask: "f0", limit: 1000 }) + .success, + ).toBe(true); +}); + describe("read_bytes contract", () => { it("accepts lengths above the former arbitrary ceiling and rejects zero", () => { const contract = OFFICIAL_TOOL_CONTRACTS.find( diff --git a/tests/boundary/mcp/oversizedErrorDeliveryMcp.test.ts b/tests/boundary/mcp/oversizedErrorDeliveryMcp.test.ts index f03d349b2..10d081d84 100644 --- a/tests/boundary/mcp/oversizedErrorDeliveryMcp.test.ts +++ b/tests/boundary/mcp/oversizedErrorDeliveryMcp.test.ts @@ -257,3 +257,72 @@ it("keeps the untrusted target-content marker on an oversized error's replacemen await Promise.all([client.close(), server.close()]); } }); + +// The delivered error wire omits only the private structured carrier; its +// `isError` flag and any provenance marker count against the result budget. +const deliveredErrorBytes = (nameBytes: number, targetContent: boolean) => { + const { structuredContent: _carrier, ...wire } = failureWithName( + nameBytes, + targetContent, + ); + return Buffer.byteLength(JSON.stringify(wire)); +}; + +it.each([false, true])( + "applies the result budget to the complete delivered error (target content: %s)", + async (targetContent) => { + const fittingName = + delivery.resultBudgetBytes - deliveredErrorBytes(0, targetContent); + expect(deliveredErrorBytes(fittingName, targetContent)).toBe( + delivery.resultBudgetBytes, + ); + const server = new EvidenceMcpServer( + { name: "error-budget-test", version: "1" }, + { capabilities: {} }, + undefined, + delivery, + ); + server.registerTool( + "fitting_failure", + { inputSchema: z.object({}) }, + async () => failureWithName(fittingName, targetContent), + ); + server.registerTool( + "one_byte_over_failure", + { inputSchema: z.object({}) }, + async () => failureWithName(fittingName + 1, targetContent), + ); + const client = new Client({ name: "error-budget-client", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const fitting = await client.callTool({ + name: "fitting_failure", + arguments: {}, + }); + expect(fitting.isError).toBe(true); + expect(parseMcpToolError(fitting)).toEqual( + failureWithName(fittingName, targetContent).structuredContent, + ); + const over = await client.callTool({ + name: "one_byte_over_failure", + arguments: {}, + }); + expect(over.isError).toBe(true); + expect(over.structuredContent).toBeUndefined(); + expect(parseMcpToolError(over)).toMatchObject({ + error: { + code: "resource_constraint", + details: { + reported_limits: { original_error_code: "invalid_request" }, + }, + }, + }); + } finally { + await Promise.all([client.close(), server.close()]); + } + }, + 20000, +); diff --git a/tests/evaluation/model-evals/knownAnswerEvaluation.test.ts b/tests/evaluation/model-evals/knownAnswerEvaluation.test.ts index cbe59dd7b..daed23cac 100644 --- a/tests/evaluation/model-evals/knownAnswerEvaluation.test.ts +++ b/tests/evaluation/model-evals/knownAnswerEvaluation.test.ts @@ -5,6 +5,16 @@ import { toolContract } from "../../../src/contracts/toolContracts.js"; import { createEvidence, type Evidence } from "../../../src/domain/evidence.js"; import type { JsonValue } from "../../../src/domain/jsonValue.js"; import { evaluateCodexEvents } from "../../../src/evaluation/CodexAgentEval.js"; +import { + agentFixtureAnswerInstructions, + agentFixtureClaims, +} from "../../../src/evaluation/AgentEvaluationFixtures.js"; +import { inspectManagedArtifactBytes } from "../../../src/dotnet/ManagedArtifactInspector.js"; +import { inspectManagedMembersBytes } from "../../../src/dotnet/ManagedMemberInspector.js"; +import { + buildManagedPeFixture, + managedPeFixtureTarget, +} from "../../../src/dotnet/ManagedPe.fixture.js"; import type { FixtureClaimExpectation } from "../../../src/evaluation/KnownAnswerEvaluation.js"; import { ToolResultDelivery } from "../../../src/server/toolResult.js"; @@ -33,7 +43,13 @@ const createObservation = ( const resultFor = (evidence: Evidence) => delivery.toEvidenceToolResult( evidence, - toolContract("analyze_javascript_application"), + toolContract( + evidence.operation === "inspect_managed_artifact" + ? "inspect_managed_artifact" + : evidence.operation === "inspect_managed_members" + ? "inspect_managed_members" + : "analyze_javascript_application", + ), undefined, ); const delivered = ( @@ -95,6 +111,336 @@ const assess = ( ); const answer = (claims: readonly unknown[]) => JSON.stringify({ claims }); +// Synthetic transcript cases exercise grading, not an SDK-built assembly oracle. +const managedTranscript = () => { + const bytes = buildManagedPeFixture({ + typeNamespace: "AgentEval", + typeName: "Program", + methodName: "Main", + }); + const target = managedPeFixtureTarget(bytes, "/targets/AgentEval.dll"); + const artifact = inspectManagedArtifactBytes(bytes, target); + const members = inspectManagedMembersBytes(bytes, target); + const program = members.types[0]; + if (program === undefined) throw new Error("Fixture type missing"); + const result = { + ...members, + types: [ + program, + { + ...program, + token: "0x02000002", + name: "Profile", + full_name: "AgentEval.Profile", + }, + ], + }; + const rubric = agentFixtureClaims("managed", { + javascript: "", + javascriptSha256: "", + javascriptShapeLeft: "", + javascriptShapeRight: "", + managed: target.path, + managedSha256: target.sha256, + }); + if (rubric === undefined) throw new Error("Managed rubric missing"); + const evidenceFor = ( + operation: string, + value: JsonValue, + overrides: Record = {}, + ) => + createEvidence(target, provider, { + operation, + parameters: {}, + result: value, + confidence: "observed", + authority: "shipped-artifact", + ...overrides, + }); + const artifactEvidence = evidenceFor("inspect_managed_artifact", artifact); + const memberEvidence = evidenceFor("inspect_managed_members", result); + const textFor = (evidence: Evidence) => + answer([ + { + id: "public_types", + value: ["AgentEval.Profile", "AgentEval.Program"], + evidence_id: evidence.evidence_id, + confidence: "observed", + authority: "shipped-artifact", + }, + { + id: "entry_point", + value: { declaring_type: "AgentEval.Program", name: "Main" }, + evidence_id: evidence.evidence_id, + confidence: "observed", + authority: "shipped-artifact", + }, + ]); + const grade = ( + events = [delivered(artifactEvidence), delivered(memberEvidence)], + text = textFor(memberEvidence), + ) => + evaluateCodexEvents([...events, final(text)], "inspect_managed_artifact", { + fixtureClaims: rubric, + }); + return { + artifact, + result, + artifactEvidence, + memberEvidence, + evidenceFor, + textFor, + grade, + }; +}; + +describe("managed known-answer fixture grading", () => { + it("grades public types independent of row order and resolves the header token", () => { + expect(managedTranscript().grade().factualCorrectness).toBe("passed"); + }); + + it("does not pin compiler-local entry tokens or MVIDs", () => { + const fixture = managedTranscript(); + const artifact = structuredClone(fixture.artifact); + const members = structuredClone(fixture.result); + if ( + artifact.pe.cli === null || + artifact.module === null || + members.module === null || + members.methods[0] === undefined + ) + throw new Error("Fixture identity missing"); + artifact.pe.cli.entry_point.value = "0x06000009"; + members.methods[0].token = "0x06000009"; + const mvid = "11111111-2222-3333-4444-555555555555"; + artifact.module.mvid = mvid; + members.module.mvid = mvid; + members.identity_scope.requires_mvid = mvid; + members.types.reverse(); + const artifactEvidence = fixture.evidenceFor( + "inspect_managed_artifact", + artifact, + ); + const memberEvidence = fixture.evidenceFor( + "inspect_managed_members", + members, + ); + expect( + fixture.grade( + [delivered(artifactEvidence), delivered(memberEvidence)], + fixture.textFor(memberEvidence), + ).factualCorrectness, + ).toBe("passed"); + }); + + it.each([ + "wrong-token", + "native-rva", + "mvid", + "null-mvid", + "digest", + "partial", + ])("rejects an unsupported artifact entry point: %s", (kind) => { + const fixture = managedTranscript(); + const artifact = structuredClone(fixture.artifact); + if (artifact.pe.cli === null || artifact.module === null) + throw new Error("Fixture identity missing"); + if (kind === "wrong-token") + artifact.pe.cli.entry_point.value = "0x06000002"; + if (kind === "native-rva") artifact.pe.cli.entry_point.kind = "native-rva"; + if (kind === "mvid") + artifact.module.mvid = "11111111-2222-3333-4444-555555555555"; + if (kind === "null-mvid") artifact.module.mvid = null; + if (kind === "digest") artifact.artifact.sha256 = "b".repeat(64); + if (kind === "partial") artifact.coverage.state = "partial"; + const evidence = fixture.evidenceFor("inspect_managed_artifact", artifact); + expect( + fixture.grade([delivered(evidence), delivered(fixture.memberEvidence)]) + .factualCorrectness, + ).toBe("failed"); + }); +}); + +describe("managed inventory scope", () => { + it.each(["selection", "parameters", "both"])( + "rejects selected public-type inventories while retaining entry-point grounding: %s", + (kind) => { + const fixture = managedTranscript(); + const typeNames = ["AgentEval.Profile", "AgentEval.Program"]; + const members = { + ...fixture.result, + selection: + kind === "parameters" + ? null + : { type_names: typeNames, unmatched_type_names: [] }, + }; + const evidence = fixture.evidenceFor("inspect_managed_members", members, { + parameters: kind === "selection" ? {} : { type_names: typeNames }, + }); + expect( + fixture.grade( + [delivered(fixture.artifactEvidence), delivered(evidence)], + fixture.textFor(evidence), + ), + ).toMatchObject({ + factualCorrectness: "failed", + factualAssessment: { + claims: [ + { id: "public_types", status: "failed" }, + { id: "entry_point", status: "passed" }, + ], + }, + }); + }, + ); +}); + +describe("managed member grounding", () => { + it.each([ + "extra-public-type", + "missing-public-type", + "duplicate-method", + "decoy-main", + "scope-digest", + "scope-mvid", + "null-scope", + "partial", + "unavailable", + ])("rejects unsupported member facts: %s", (kind) => { + const fixture = managedTranscript(); + const members = structuredClone(fixture.result); + const method = members.methods[0]; + const type = members.types[0]; + if (method === undefined || type === undefined) + throw new Error("Fixture members missing"); + if (kind === "extra-public-type") + members.types.push({ + ...type, + name: "Extra", + full_name: "AgentEval.Extra", + }); + if (kind === "missing-public-type") members.types.pop(); + if (kind === "duplicate-method") members.methods.push(method); + if (kind === "decoy-main") { + method.name = "Other"; + members.methods.push({ ...method, token: "0x06000002", name: "Main" }); + } + if (kind === "scope-digest") + members.identity_scope.requires_artifact_sha256 = "b".repeat(64); + if (kind === "scope-mvid") + members.identity_scope.requires_mvid = + "11111111-2222-3333-4444-555555555555"; + if (kind === "null-scope") members.identity_scope.requires_mvid = null; + if (kind === "partial") members.metadata.status = "partial"; + if (kind === "unavailable") members.coverage.state = "unavailable"; + const evidence = fixture.evidenceFor("inspect_managed_members", members); + expect( + fixture.grade( + [delivered(fixture.artifactEvidence), delivered(evidence)], + fixture.textFor(evidence), + ).factualCorrectness, + ).toBe("failed"); + }); + + it("rejects distinct authenticated matching artifact deliveries", () => { + const fixture = managedTranscript(); + const secondArtifact = fixture.evidenceFor( + "inspect_managed_artifact", + fixture.artifact, + { parameters: { path: "/targets/AgentEval.dll" } }, + ); + expect(secondArtifact.evidence_id).not.toBe( + fixture.artifactEvidence.evidence_id, + ); + const member = delivered(fixture.memberEvidence); + expect( + fixture.grade([delivered(secondArtifact), member]).factualCorrectness, + ).toBe("passed"); + expect( + fixture.grade([ + delivered(fixture.artifactEvidence), + delivered(secondArtifact), + member, + ]).factualCorrectness, + ).toBe("failed"); + }); + + it("accepts identical repeated artifact deliveries", () => { + const fixture = managedTranscript(); + expect( + fixture.grade([ + delivered(fixture.artifactEvidence), + delivered(fixture.artifactEvidence), + delivered(fixture.memberEvidence), + ]).factualCorrectness, + ).toBe("passed"); + }); + + it("requires companion delivery and rejects conflicting or failed artifact deliveries", () => { + const fixture = managedTranscript(); + const member = delivered(fixture.memberEvidence); + expect(fixture.grade([member]).factualCorrectness).toBe("failed"); + expect( + fixture.grade([ + delivered(fixture.artifactEvidence, { status: "failed" }), + member, + ]).factualCorrectness, + ).toBe("failed"); + const conflicting = { + ...fixture.artifactEvidence, + subject: + fixture.memberEvidence.subject === null + ? null + : { ...fixture.memberEvidence.subject, local_path: "/other.dll" }, + }; + expect( + fixture.grade([ + delivered(fixture.artifactEvidence), + delivered(conflicting), + member, + ]).factualCorrectness, + ).toBe("failed"); + }); + + it("binds the digest, rejects fabricated values and cites the supporting operation", () => { + const fixture = managedTranscript(); + expect( + fixture.grade(undefined, fixture.textFor(fixture.artifactEvidence)) + .factualCorrectness, + ).toBe("failed"); + expect( + fixture.grade( + undefined, + fixture.textFor(fixture.memberEvidence).replace('"Main"', '"Invented"'), + ).factualCorrectness, + ).toBe("failed"); + const other = createEvidence( + { path: "/targets/AgentEval.dll", sha256: "b".repeat(64), format: "pe" }, + provider, + { + operation: "inspect_managed_members", + parameters: {}, + result: fixture.result, + confidence: "observed", + authority: "shipped-artifact", + }, + ); + expect( + fixture.grade( + [delivered(fixture.artifactEvidence), delivered(other)], + fixture.textFor(other), + ).factualCorrectness, + ).toBe("failed"); + }); + + it("keeps golden names out of instructions and preserves routing-only scenarios", () => { + const instructions = agentFixtureAnswerInstructions("managed"); + expect(instructions).toContain("do not read target files directly"); + expect(instructions).not.toMatch(/AgentEval|Profile|Program|Main/u); + expect(agentFixtureAnswerInstructions("native")).toBe(""); + }); +}); + // Removing exact fixture comparison or accepted-source validation must break these regressions. describe("known-answer fixture grading", () => { it("passes closed claims against authenticated production MCP Evidence", () => { diff --git a/tests/fixtures/clientEnvironment.ts b/tests/fixtures/clientEnvironment.ts index 9825caeec..5e2ba94c2 100644 --- a/tests/fixtures/clientEnvironment.ts +++ b/tests/fixtures/clientEnvironment.ts @@ -27,6 +27,7 @@ export const CLIENT_LOCATION_ENVIRONMENT = [ "GEMINI_CLI_SYSTEM_DEFAULTS_PATH", "HERMES_DATA_DIR_SUFFIX", "HERMES_HOME", + "KIMI_CODE_HOME", "LOCALAPPDATA", "OMP_PROFILE", "OPENCODE_CONFIG", diff --git a/tests/fixtures/fakeGhidra.mjs b/tests/fixtures/fakeGhidra.mjs index b08410793..0a130c744 100644 --- a/tests/fixtures/fakeGhidra.mjs +++ b/tests/fixtures/fakeGhidra.mjs @@ -121,7 +121,7 @@ const handleRequest = (socket, server, request, state) => { ? [stringItem("0x2000", "fixture 雪🦊")] : request.method === "list_strings" && state.mode === "oversized_result" ? [stringItem("0x2000", "x".repeat(64 * 1024 * 1024 + 1))] - : inventoryResultFor(request.method); + : inventoryResultFor(request.method, request.params); if (inventory !== undefined) { const response = `${JSON.stringify({ id: request.id, ok: true, result: inventory })}\n`; if (state.mode === "fragmented") { @@ -228,6 +228,7 @@ const sessionInfo = ({ "shutdown", "inspect_native_load_image", "read_bytes", + "search_bytes", "address_to_file_offset", "address_name", "list_documents", @@ -273,8 +274,42 @@ const sessionInfo = ({ }, }); -const inventoryResultFor = (method) => { +const inventoryResultFor = (method, params) => { switch (method) { + case "search_bytes": { + const continued = params.cursor === "fixture-second-page"; + const paged = params.limit === 1 && !continued; + const addresses = continued + ? ["0x1005"] + : paged + ? ["0x1000"] + : ["0x1000", "0x1005"]; + return { + matches: addresses.map((address) => ({ + address, + segment: ".text", + file_offset: address === "0x1000" ? 0 : 5, + procedure: { address: "0x1000", name: "fixture_main" }, + })), + searched: { + blocks: [ + { + segment: ".text", + start: continued ? "0x1005" : "0x1000", + end: "0x1009", + bytes: continued ? 5 : 10, + }, + ], + bytes: continued ? 5 : 10, + skipped_uninitialized: [ + { segment: ".bss", start: "0x2000", end: "0x200f", bytes: 16 }, + ], + skipped_not_loaded: [], + }, + complete: !paged, + next_cursor: paged ? "fixture-second-page" : null, + }; + } case "address_name": return "fixture_main"; case "list_documents": diff --git a/tests/process-global/adbStartMcp.test.ts b/tests/process-global/adbStartMcp.test.ts new file mode 100644 index 000000000..ac34b61e6 --- /dev/null +++ b/tests/process-global/adbStartMcp.test.ts @@ -0,0 +1,147 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { expect, it, onTestFinished, vi } from "vitest"; + +import { AdbProvider } from "../../src/adb/AdbProvider.js"; +import { parseEvidence } from "../../src/domain/evidence.js"; +import { createServer } from "../../src/server/createServer.js"; +import { createTestBinarySession } from "../fixtures/binarySession.js"; +import { + execFileBytes, + execFileOutput, +} from "../../src/process/ExecFileOutput.js"; +import { toolContract } from "../../src/contracts/toolContracts.js"; + +vi.mock("../../src/process/ExecFileOutput.js", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("../../src/process/ExecFileOutput.js") + >()), + execFileOutput: vi.fn(), + execFileBytes: vi.fn(), +})); + +it.each(["start_adb_app", "start_adb_activity"] as const)( + "%s retains launch evidence through MCP with isolated process mocks", + async (operation) => { + const starting = + "Starting: Intent { act=android.intent.action.MAIN cmp=com.example/.Main }\n"; + let stdout = `${starting}Status: ok\nActivity: com.example/.Main\nWaitTime: 315\nComplete\n`; + let stderr = ""; + vi.mocked(execFileOutput).mockResolvedValue({ + stdout: "Android Debug Bridge version 1.0.41\nVersion 34.0.5-stub\n", + stderr: "", + }); + const calls: string[][] = []; + vi.mocked(execFileBytes).mockImplementation(async (_binary, args) => { + calls.push([...args]); + return args.includes("resolve-activity") + ? { + stdout: Buffer.from("com.example/.Main\n"), + stderr: Buffer.alloc(0), + } + : { stdout: Buffer.from(stdout), stderr: Buffer.from(stderr) }; + }); + onTestFinished(() => { + vi.resetAllMocks(); + }); + const server = createServer( + { + kind: "session", + session: createTestBinarySession(() => { + throw new Error("must not start deep provider"); + }), + }, + { adbDeviceAnalysis: new AdbProvider({ environment: {} }) }, + ); + onTestFinished(() => server.close()); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name: "adb-start-boundary", version: "0.0.0" }); + onTestFinished(() => client.close()); + await client.connect(clientTransport); + const input = + operation === "start_adb_app" + ? { serial: "emulator-5554", package: "com.example" } + : { + serial: "emulator-5554", + action: "android.intent.action.MAIN", + component: "com.example/.Main", + }; + const successful = await client.callTool({ + name: operation, + arguments: input, + }); + expect(successful.isError).not.toBe(true); + const evidence = toolContract(operation).outputSchema.parse( + parseEvidence( + JSON.parse( + (successful.content as { text?: string }[]) + .map((part) => part.text ?? "") + .join(""), + ), + ), + ); + expect(evidence.confidence).toBe("observed"); + expect(evidence.normalized_result).toMatchObject({ + started_activity: "act=android.intent.action.MAIN cmp=com.example/.Main", + reported_activity: "com.example/.Main", + reported_status: "ok", + }); + expect(evidence.raw_result).toEqual({ stdout, stderr }); + + stdout = `${starting}Status: timeout\nActivity: com.example/.Main\nWaitTime: 10004\nComplete\n`; + const timedOut = await client.callTool({ + name: operation, + arguments: input, + }); + expect(timedOut.isError).toBe(true); + const failure = JSON.parse( + (timedOut.content as { text?: string }[]) + .map((part) => part.text ?? "") + .join(""), + ); + expect(failure.error).toMatchObject({ + code: "unsupported_target", + details: { + captured_output: { stdout, stderr, truncated: false }, + partial_observation: { + provider_id: "adb", + operation, + result: { + started_activity: + "act=android.intent.action.MAIN cmp=com.example/.Main", + reported_activity: "com.example/.Main", + reported_status: "timeout", + }, + }, + }, + }); + expect(failure).not.toHaveProperty("confidence"); + + stdout = starting; + stderr = "Error: Activity class does not exist\n"; + const refused = await client.callTool({ + name: operation, + arguments: input, + }); + expect(refused.isError).toBe(true); + expect( + (refused.content as { text?: string }[]) + .map((part) => part.text ?? "") + .join(""), + ).toContain("Activity class does not exist"); + expect(calls.filter((args) => args.includes("start"))).toEqual( + Array.from({ length: 3 }, () => [ + "-s", + "emulator-5554", + "shell", + "am", + "start", + "-W", + ...(operation === "start_adb_app" + ? ["-n", "com.example/.Main"] + : ["-a", "android.intent.action.MAIN", "-n", "com.example/.Main"]), + ]), + ); + }, +);