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:
commit
98d26a79d1
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,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"),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.";
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
};
|
||||
|
|
@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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 }) =>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ const doctorHost = (
|
|||
id: "ubuntu",
|
||||
versionId: "24.04",
|
||||
packageFamily: "deb",
|
||||
supported: true,
|
||||
hopperSetupSupported: true,
|
||||
}),
|
||||
validTarget: () => Promise.resolve(true),
|
||||
executable: (path) =>
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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))) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
|
|
@ -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");
|
||||
},
|
||||
);
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -49,6 +49,9 @@ const observeAgain = (
|
|||
result,
|
||||
confidence: source.confidence,
|
||||
authority: source.authority,
|
||||
...(source.relationship_coverage === null
|
||||
? {}
|
||||
: { relationshipCoverage: source.relationship_coverage }),
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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-");
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
Loading…
Reference in New Issue