chore: sync current main into the CI baseline repair

Validate the formatting and reconstruction assertion repairs against the
latest merged feature and correctness changes.

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
This commit is contained in:
N0zoM1z0 2026-10-13 15:43:57 +00:00
commit 98d26a79d1
79 changed files with 3246 additions and 236 deletions

View File

@ -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<String> 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<MemoryBlock> 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);

View File

@ -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),

View File

@ -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.

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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

1 harm_rank location trigger current_copy user_risk proposed_replacement implementation_status verification_result
2 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
3 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
4 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
5 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
6 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
7 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

View File

@ -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"),
}
: {}),
};
}

View File

@ -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);

View File

@ -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 });
}
});
});

View File

@ -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 {

View File

@ -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"
)

View File

@ -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"

View File

@ -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),

View File

@ -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<DoctorCheck> => {
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<DoctorCheck> => {
: `${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",
});
};

View File

@ -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]);

View File

@ -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 {

View File

@ -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;

View File

@ -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";

View File

@ -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";

View File

@ -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,
}
: {}),
};
};

View File

@ -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.";
};

View File

@ -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 &&

View File

@ -72,6 +72,15 @@ interface CanonicalSkillFile {
readonly original: SkillFileState | undefined;
}
const canonicalSkillContent = (relativePath: string): Promise<string> =>
readFile(
new URL(
`../../skills/${PRODUCT_IDENTITY.skillName}/${relativePath}`,
import.meta.url,
),
"utf8",
);
export interface SkillInstallFileOperations {
readonly mkdir: (path: string) => Promise<void>;
readonly backup: (source: string, destination: string) => Promise<void>;
@ -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<NodeJS.ProcessEnv>,
platform: NodeJS.Platform,
resources?: ArtifactResourceScope,
): Promise<boolean> => {
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<boolean> => {
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 ||

View File

@ -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",

View File

@ -627,11 +627,16 @@ const removeManagedSkills = async (
platform: NodeJS.Platform,
writeBlocker?: (path: string) => string | undefined,
): Promise<UninstallItem> => {
// 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");

View File

@ -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 }),
});

View File

@ -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,

View File

@ -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<string> {
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<string>,
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,

View File

@ -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);

View File

@ -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",

View File

@ -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",

View File

@ -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:

View File

@ -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" },

View File

@ -438,6 +438,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
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,

View File

@ -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<Record<string, z.ZodObject>> = {
search_bytes: evidenceResultOf(byteSearchResultSchema),
annotate_native_function: evidenceResultOf(nativeFunctionAnnotationsSchema),
inspect_native_load_image: evidenceResultOf(nativeLoadImageSchema),
inspect_native_data_type: evidenceResultOf(nativeDataTypeSchema),

View File

@ -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(

View File

@ -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)),
);

View File

@ -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<string, { start: bigint; end: bigint }>();
const closedSpaces = new Set<string>();
let previousSpace: string | null = null;
const bounds = new Map<string | null, { start: bigint; end: bigint }>();
const closedSpaces = new Set<string | null>();
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 (

View File

@ -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 = (

View File

@ -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) };
};

View File

@ -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" },
]);
});
});

View File

@ -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",
});
});

View File

@ -53,6 +53,7 @@ const source = (
digit: string,
observation: Partial<EvidenceObservation> = {},
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", {

View File

@ -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 }) =>

View File

@ -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);
});

View File

@ -50,7 +50,8 @@ export interface AnalysisAccumulator {
readonly seen: Set<string>;
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 = (

View File

@ -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

View File

@ -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<FixtureClaimExpectation["source"], "select">;
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<Record<string, readonly string[]>> = {
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.',

View File

@ -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,
)
)

View File

@ -61,7 +61,7 @@ const doctorHost = (
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
supported: true,
hopperSetupSupported: true,
}),
validTarget: () => Promise.resolve(true),
executable: (path) =>

View File

@ -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({

View File

@ -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,

View File

@ -62,12 +62,16 @@ const MUTATING_OPERATIONS = new Set<string>([
/** 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,

View File

@ -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") {

View File

@ -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,

View File

@ -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(

View File

@ -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

View File

@ -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 });
});

View File

@ -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))) {

View File

@ -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<string, unknown>();
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,
);
}

View File

@ -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);
},
);

View File

@ -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<Record<string, string>> =>
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");
},
);

View File

@ -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",

View File

@ -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) =>

View File

@ -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,
);
});

View File

@ -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<typeof supportedClients>[2] = {},
) => supportedClients(home, platform, env).find(({ name }) => name === "kimi");
const exists = async (path: string): Promise<boolean> => {
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" });
});
});

View File

@ -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 });

View File

@ -49,6 +49,9 @@ const observeAgain = (
result,
confidence: source.confidence,
authority: source.authority,
...(source.relationship_coverage === null
? {}
: { relationshipCoverage: source.relationship_coverage }),
},
);

View File

@ -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-");

View File

@ -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",

View File

@ -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);

View File

@ -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<void> }> = [];
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(

View File

@ -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,
);

View File

@ -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<string, unknown> = {},
) =>
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", () => {

View File

@ -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",

View File

@ -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":

View File

@ -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"]),
]),
);
},
);