Compare commits

..
Author SHA1 Message Date
morluto 6b9374bb93 fix(native): reconcile procedure pages after docs move 2026-11-08 01:49:23 +08:00
morluto c2077c9f3b fix(native): reconcile current main formatting 2026-11-07 23:17:06 +08:00
morluto 1693e074b0 fix(native): reconcile procedure reference views with main 2026-11-07 22:40:05 +08:00
Cursor Agent 9975a60381 ci: rerun after the binary-layout toolchain timeout
The required check failed because the toolchain install exceeded 15 minutes.
The same job completed on the previous head, and this commit does not change
the procedure-reference behavior.
2026-11-04 22:50:57 +00:00
Cursor Agent e57e4c72e7 test(native): catch the string-length endpoint comparison
Use a 200,000-character multibyte selected procedure and a shorter ASCII
endpoint so the old JavaScript length check drops the selected row.
2026-11-04 22:29:15 +00:00
Cursor Agent b436f44b58 test(cli): count procedure-references as a dedicated command
The MCP tool has a CLI command, so it is no longer an MCP-only exception.
2026-11-04 22:06:40 +00:00
Cursor Agent 025f10a848 fix(native): compare endpoint size with UTF-8 identity length
Keep one running maximum of stored identity bytes. A multibyte selected
procedure is recognized when its JSON no longer fits the page.
2026-11-04 21:45:13 +00:00
Cursor Agent 9ebef9c8d1 fix(native): describe procedure-reference direction and coverage
Avoid execution wording in the tool description, and give the CLI direction
option the help text the catalog requires.
2026-11-04 21:39:04 +00:00
Cursor Agent 255865c727 fix(native): reject an oversized procedure endpoint before canonicalizing it
When the JSON size already exceeds every stored identity, skip the canonical
string and keep the row out of the page.
2026-11-04 21:22:45 +00:00
Cursor Agent acad91bb54 fix(native): measure a procedure endpoint once before its comparison key
Size the candidate before building one canonical identity, and reuse that
key for the selected-procedure and shared-endpoint checks.
2026-11-04 21:00:44 +00:00
Cursor Agent 5833bb7c9b Merge origin/main into codex/procedure-reference-stacked
Keep procedure-reference pages beside the landed instruction-reference view
and the value-flow parameter description.
2026-11-04 20:38:25 +00:00
Cursor Agent b7ebd60341 fix(native): count procedure-reference endpoints before keeping a row
Include the shared endpoint table in the page budget, with JSON punctuation,
and store a new procedure only when its row is accepted.
2026-11-04 20:12:32 +00:00
Cursor Agent 42e8fa871b fix(native): document shared procedure endpoints and the required selector
Describe kind shared and items.endpoints instead of inline procedure copies.
State that procedure-references requires a positional selector or --procedure.
2026-11-04 20:08:07 +00:00
Cursor Agent 1a4986718c merge: page procedure references without repeated scans
Bring the branch onto main. Unfiltered pages use the collection length
and only the requested window. Repeated procedure identities are shared
once per page. The CLI accepts --procedure, and procedure_references
uses the shared analysis Evidence predicate.
2026-11-04 19:57:10 +00:00
Cursor Agent 5c283971bc Merge origin/main into codex/procedure-reference-stacked
Retarget onto main after the feature-trace squash. Keep procedure-reference
pages with the landed feature-trace and batch-decompile view text.
2026-11-04 19:29:08 +00:00
Cursor Agent 7b0f6299bf Merge codex/native-feature-trace-views into codex/procedure-reference-stacked
Keep the procedure-reference description with the updated feature-trace text
and the batch-decompile unavailable status example from main.
2026-11-04 18:11:15 +00:00
Cursor Agent 6ffa15c4d6 Merge origin/main into codex/native-feature-trace-views
Keep the native feature-trace view description and main's batch-decompile
status example that includes unavailable rows.
2026-11-04 18:10:11 +00:00
Cursor Agent 27b90d5d59 ci: rerun pull request checks
The merge of origin/main did not start the CI workflow for this head.
2026-11-04 18:06:30 +00:00
Cursor Agent 090dacd526 Merge remote-tracking branch 'origin/main' into codex/native-feature-trace-views 2026-11-04 17:52:17 +00:00
Cursor Agent b403205338 Merge remote-tracking branch 'origin/main' into codex/procedure-reference-stacked 2026-11-04 17:51:21 +00:00
Cursor Agent 7d956fa9bf Merge branch 'codex/native-feature-trace-views' into codex/procedure-reference-stacked 2026-11-04 17:50:42 +00:00
Cursor Agent 51cfd08d45 fix(cli): report result sources when Evidence publication fails
A trace keeps linked sources on the result. Use those records for the
undelivered source closure as well as the bundle, so a refused output
does not report them as not captured.
2026-11-04 17:50:36 +00:00
Cursor Agent fb5f1ec805 Merge codex/native-feature-trace-views into codex/procedure-reference-stacked
Update the stacked branch with the base that now contains origin/main. Keep
procedure-reference pages together with the landed feature-trace, value-trace,
and batch-decompile views.
2026-11-04 17:11:23 +00:00
Cursor Agent bb73c66baf Merge origin/main into codex/native-feature-trace-views
Keep one-shot Evidence bundle capture and linked source closure together,
including snapshot replay and immutable bundle publication.
2026-11-04 17:08:31 +00:00
morluto 1fc86c45aa docs(mcp): guide trace reference classification 2026-11-04 10:15:18 +08:00
morluto de9d468211 docs(mcp): clarify imported residual questions 2026-11-04 10:02:56 +08:00
morluto ec05b9de27 merge: integrate main into native trace views 2026-11-03 19:31:43 +08:00
morluto 0fabb2f7b8 feat(native): page retained procedure references 2026-11-02 03:19:29 +08:00
morluto 2a0415cd5b test(snapshot): verify linked foreign evidence stays scoped 2026-11-02 02:56:23 +08:00
morluto 673d971381 fix(snapshot): retain linked source evidence for replay 2026-11-02 02:54:41 +08:00
morluto 4809afeef5 feat(native): page retained feature traces 2026-11-02 02:43:25 +08:00
310 changed files with 2353 additions and 1408 deletions

No files matched your search

@@ -325,8 +325,7 @@ on the first MCP call. It retains the complete observation and returns the
original selection, text availability and length without the body. Use
`normalized_result.parent_evidence_id` with `inspect_analysis_view` and
`view: {kind: "native", facet: "pseudocode", offset: 0, limit: 20000}`;
follow `coverage.next_offset` for later pages. Choose a smaller limit if one
page exceeds the client or transport budget. Offsets and limits count UTF-16
follow `coverage.next_offset` for later pages. Offsets and limits count UTF-16
code units, not lines or tokens. Pages reuse the same observation without
repeating decompilation. Null and empty text remain distinct. Omitted or
`complete` detail preserves full inline delivery. Summary delivery requires a
+3 -11
View File
@@ -876,7 +876,6 @@ public final class ReaGhidraBridge extends HeadlessScript {
JsonArray blocks = new JsonArray();
JsonArray skipped = new JsonArray();
JsonArray notLoaded = new JsonArray();
JsonArray overlays = new JsonArray();
List<ByteSearchBlock> eligible = new ArrayList<>();
for (MemoryBlock block : ordered) {
monitor.checkCancelled();
@@ -884,16 +883,10 @@ public final class ReaGhidraBridge extends HeadlessScript {
Address high = block.getEnd();
Address bound = start == null ? end : start;
if (bound != null && !bound.getAddressSpace().equals(low.getAddressSpace())) continue;
// Without bounds, search loaded non-overlay memory. Unloaded file
// parts and loaded overlays stay reachable through explicit bounds.
if (bound == null && !block.isLoaded()) {
if (bound == null && (!block.isLoaded() || block.isOverlay())) {
notLoaded.add(byteSearchCoverage(block, low, high));
continue;
}
if (bound == null && block.isOverlay()) {
overlays.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;
@@ -967,7 +960,6 @@ public final class ReaGhidraBridge extends HeadlessScript {
searched.addProperty("bytes", covered);
searched.add("skipped_uninitialized", skipped);
searched.add("skipped_not_loaded", notLoaded);
searched.add("skipped_overlays", overlays);
JsonObject result = new JsonObject();
result.add("matches", matches);
result.add("searched", searched);
@@ -2384,7 +2376,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
if (!match.matched()) {
continue;
}
JsonObject item = resultItem(entry.item, "procedure");
JsonObject item = resultItem(entry.item, null);
if (!match.aliases().isEmpty()) {
JsonArray aliases = new JsonArray();
for (String alias : match.aliases()) aliases.add(alias);
@@ -2399,7 +2391,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
if (!matcher.matches(item.value)) {
continue;
}
items.add(resultItem(item, procedureSearch ? "procedure" : "string"));
items.add(resultItem(item, null));
}
return items;
}
@@ -100,6 +100,36 @@ body are excluded. `procedure_references` returns internal and external edges;
The reference collector's semantics participate in the analysis profile, so
older snapshots cannot replay the former incomplete results under this profile.
For a bounded handoff, request `procedure_references` with `detail: "summary"`.
The summary retains the complete observation and reports the selected procedure,
direction and counts. Page it with `inspect_analysis_view`, using the summary's
`normalized_result.parent_evidence_id` and a view such as
`{"kind":"native-procedure-references","collection":"references","source_address":"0x1234","offset":0,"limit":64}`.
The optional exact source and target filters apply before paging. A row's
`source_index` identifies it in the original retained collection, including
duplicates. Endpoint `kind: "selected-procedure"` refers to the full procedure
in the page summary; `kind: "null"` preserves the original null without asserting
why it was unavailable. `kind: "shared"` carries an index into `items.endpoints`,
where each other procedure object is stored once per page. Use collection
`unresolved_calls` for paged unresolved call sites and their original reasons.
Page coverage describes retained rows, not complete provider discovery or runtime
execution. A 1 MiB projection allowance, also used by other bounded analysis views, can
shorten a requested page; `byte_limited: true` and `coverage.next_offset` identify the
continuation. A single row or summary that cannot fit returns a resource error;
retrieve or export the complete parent Evidence. This allowance has no runtime
override. Omit detail to receive the complete observation inline.
For a one-shot CLI handoff, save the complete Evidence and its linked sources:
```bash
rea procedure-references /absolute/path/to/program my_function --provider ghidra \
--direction incoming --detail summary --evidence_output /absolute/path/to/references.json
```
Use `rea inspect-analysis-view` with that bundle as an `evidence-file` source to
page it without reopening the target. This command requires a provider supporting
`procedure_references`; Kuna currently reports that capability unavailable.
Ghidra regex searches use Java Pattern semantics. If compiling or matching a
pattern exhausts the engine's stack, REA returns a resource constraint and
preserves the active database and annotations. Retry in literal mode or simplify
+1 -1
View File
@@ -184,7 +184,7 @@ REA_KUNA_COMMAND=/absolute/path/to/kuna GHIDRA_INSTALL_DIR=/absolute/path/to/ghi
npm run verify:kuna -- --targets DIR --functions inflate,crc32 --compare-ghidra
```
The lane compiles a small source-owned fixture with the host C compiler and verifies the MCP workflow end to end. `--targets DIR --functions a,b` adds caller-built binaries. `--compare-ghidra` (with `GHIDRA_INSTALL_DIR`) runs those same functions through Ghidra; it requires `--targets` and `--functions` and refuses empty matrices or requested function names missing from every target. The report lists examined caller binaries and their matching requested symbols, including inputs omitted from analysis. See [testing](development/testing.md#kuna-lane) for coverage.
The lane compiles a small source-owned fixture with the host C compiler and verifies the MCP workflow end to end. `--targets DIR --functions a,b` adds caller-built binaries. `--compare-ghidra` (with `GHIDRA_INSTALL_DIR`) runs those same functions through Ghidra; it requires `--targets` and `--functions` and refuses empty matrices or requested function names missing from every target. See [testing](development/testing.md#kuna-lane) for coverage.
Failed runs also report process-lineage evidence and exit unsuccessfully. If provider or transport cleanup is unconfirmed, the report says `cleanup: "incomplete"` and records `retained_workspace`; that directory is preserved for investigation and cleanup retries.
+4 -18
View File
@@ -89,13 +89,6 @@ does not prove text is absent. Both list and search Evidence carry this
limitation. When an address is known, use `read_bytes` and `xrefs` to inspect
bytes and references.
String searches and `trace_feature` retain each matched row's Ghidra-reported
encoding, termination and byte length. These describe the database's defined
data, not independent proof of the original source encoding. Procedure searches
and trace matches likewise retain Ghidra's external and thunk classification.
The Ghidra analysis profile identifies this search projection, so older
snapshots with unqualified search rows cannot replay under the new profile.
## PE debug-symbol coverage
The post-analysis handshake reports Ghidra's PDB Program Information attributes:
@@ -342,17 +335,10 @@ 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 non-overlay memory blocks are eligible, in Ghidra address
order, including across adjacent initialized blocks. Blocks that are not loaded
(file parts such as `.symtab`) are listed in `searched.skipped_not_loaded`;
loaded overlay blocks are listed separately in `searched.skipped_overlays`.
Neither is searched by default; pass `start` or `end` in its address space to
search it. A block that is both unloaded and an overlay is listed once under
`skipped_not_loaded`.
The separate overlay field changes the byte-search result contract and the
Ghidra analysis profile. Refresh cached MCP schemas; snapshots from the older
profile are not replayed. Previously exported Evidence remains importable as
historical data with its original result shape.
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, including across adjacent eligible initialized
blocks in the same address space. Gaps and excluded or uninitialized blocks break
a search run. Each match reports its address, starting segment, unique source file
+3 -17
View File
@@ -448,16 +448,7 @@ caller-built functions through Ghidra (it refuses to run without `--targets` and
reports latency, sampled process-tree CPU and peak memory, UTF-8 response size,
and goto and warning-marker counts. Those counts are not a correctness judgement,
and model tokens are not measured. Reference timings and response sizes are
reported separately; reference caches expire when the server closes. The report's
`caller_target_selection` retains the selected directory, the original functions
argument, and each examined binary's requested names matched against `nm` text
symbols. `symbol_scan_complete` means the scan of non-`.stripped` entries in
the immediate flavor directories finished; it does not cover other directory
entries. An `inspection_failed` entry and `symbol_scan_complete: false` retain
partial discovery when symbol inspection fails. A missing symbol is not proof
that code is absent, and selection does not establish that a stripped counterpart
was verified; consult `results` for completed variants. `--report PATH` writes
the JSON report.
reported separately; reference caches expire when the server closes. `--report PATH` writes the JSON report.
The first matrix run used Kuna 1.833 and Ghidra 12.1.4 with JDK 21 on macOS
arm64. It covered zlib 1.3.2 `minigzip`, coreutils 9.9 `sort` and the sqlite
@@ -540,13 +531,8 @@ the entry changes without `provider_id`, preservation of a session annotation
and the target/provider receipt after a rejected open, unchanged source bytes,
and owned cleanup. Six-byte x86 and x86_64 controls also verify supported compiler
specifications, independent mappings, unchanged bytes, and decompilation of a
known return value. On a disposable Ghidra project, MCP `search_bytes` also
checks a manually created loaded `overlay_test` block: an unbounded search lists
it in `skipped_overlays` and not in `skipped_not_loaded`, and bounds in that
overlay space find the pattern. This lane does not build an overlapping-load ELF,
does not call CLI `search-bytes`, and does not page an overlay search across a
snapshot reopen. AArch64 remains covered by deterministic tests rather than this
real-provider lane. See [raw binary analysis](providers/raw-binary.md).
known return value. AArch64 remains covered by deterministic tests rather than
this real-provider lane. See [raw binary analysis](providers/raw-binary.md).
`npm run verify:ghidra:address-context` runs on Linux with a caller-installed
Ghidra, full JDK, and an x86-64 ELF GCC toolchain (`CC` selects another compatible
-8
View File
@@ -18,7 +18,6 @@ import {
} from "./mcp-verifier-results.mjs";
import { verifyGhidraBoundaries } from "./real-ghidra-boundaries.mjs";
import { verifyGhidraProjectIdentity } from "./real-ghidra-project-identity.mjs";
import { verifyGhidraSocketBoundary } from "./real-ghidra-socket-boundary.mjs";
const defaultEntrypoint = fileURLToPath(new URL("../rea.mjs", import.meta.url));
const environment = () => ({
@@ -356,12 +355,6 @@ async function verifyNativeValueAdapters(
entrypoint,
env,
});
const socketBoundary = await verifyGhidraSocketBoundary({
target,
procedure,
entrypoint,
env,
});
return {
mocked: false,
cli: true,
@@ -370,7 +363,6 @@ async function verifyNativeValueAdapters(
direct_function_comparison: true,
boundaries,
project_identity: projectIdentity,
socket_boundary: socketBoundary,
nodes: cliGraph.total_nodes,
edges: cliGraph.total_edges,
decompilations: cliGraph.decompilations,
+3 -7
View File
@@ -23,7 +23,6 @@ import { verifyGhidraEntryAliases } from "./real-ghidra-entry-aliases.mjs";
import { verifyGhidraNamespaceAnnotations } from "./real-ghidra-namespace-annotations.mjs";
import { verifyGhidraBatchIdentity } from "./real-ghidra-batch-identity.mjs";
import { verifyGhidraInstructionFlow } from "./real-ghidra-instruction-flow.mjs";
import { verifyGhidraSearchFacts } from "./real-ghidra-search-facts.mjs";
/** Probe real Ghidra location, annotation and error contracts through public adapters. */
export async function verifyGhidraBoundaries(
@@ -411,11 +410,6 @@ export async function verifyGhidraBoundaries(
(item) => item.value === "REA_GHIDRA_INVENTORY_ENTRY",
);
assert.ok(marker);
await verifyGhidraSearchFacts(call, {
strings,
procedures,
entryAddress: address,
});
assert.ok(
(
await call("search_strings", { pattern: marker.value.toLowerCase() })
@@ -604,7 +598,9 @@ export async function verifyGhidraBoundaries(
const longString = strings.find((item) => item.value === longLiteral);
assert.ok(longString, "The provider must retain the full regression literal");
const literalMatches = await call("search_strings", { pattern: longLiteral });
assert.deepEqual(literalMatches, [longString]);
assert.deepEqual(literalMatches, [
{ address: longString.address, value: longLiteral },
]);
assert.deepEqual(await cli("search", longLiteral), literalMatches);
await assert.rejects(
cli("search", regexPattern, ["--mode", "regex"]),
-58
View File
@@ -1,58 +0,0 @@
import assert from "node:assert/strict";
/** Compare public searches and composed traces with their provider inventory rows. */
export async function verifyGhidraSearchFacts(
call,
{ strings, procedures, entryAddress },
) {
const marker = strings.find(
(row) => row.value === "REA_GHIDRA_INVENTORY_ENTRY",
);
const entry = procedures.find((row) => row.address === entryAddress);
const external = procedures.find((row) => row.procedure.external);
const thunk = procedures.find((row) => row.procedure.thunk);
assert.ok(marker, "Missing source string fixture");
assert.ok(entry, "Missing selected entry procedure");
assert.ok(external, "Missing external procedure fixture");
const cases = [
{ type: "string", operation: "search_strings", row: marker },
...[entry, external, ...(thunk === undefined ? [] : [thunk])].map(
(row) => ({
type: "procedure",
operation: "search_procedures",
row,
}),
),
];
for (const { type, operation, row } of cases) {
const search = await call(operation, {
pattern: row.value,
mode: "literal",
case_sensitive: true,
});
const match = search.find(
(item) => item.address === row.address && item.value === row.value,
);
assert.deepEqual(
match,
row,
`${operation} must preserve inventory facts for ${row.value}`,
);
const trace = await call("trace_feature", {
query: row.value,
case_sensitive: true,
});
const traced = trace.matches.find(
(item) =>
item.type === type &&
item.address === row.address &&
item.value === row.value,
);
assert.deepEqual(
traced,
{ ...row, type },
`trace_feature must preserve ${operation} facts for ${row.value}`,
);
}
return { compared: cases.length, thunk_observed: thunk !== undefined };
}
-200
View File
@@ -1,200 +0,0 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import {
access,
mkdir,
mkdtemp,
readFile,
readdir,
realpath,
rm,
stat,
} from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { withVerifierCleanup } from "./verifier-cleanup.mjs";
const exec = promisify(execFile);
/** Exercise the OpenJDK Unix-socket boundary through real Ghidra and REA. */
export async function verifyGhidraSocketBoundary({
target,
procedure,
entrypoint,
env,
}) {
if (process.platform !== "darwin" && process.platform !== "linux")
return {
status: "not-applicable",
reason: "The Ghidra Unix-socket lane is not used on this host.",
};
const limit = process.platform === "darwin" ? 102 : 106;
const before = createHash("sha256")
.update(await readFile(target.path))
.digest("hex");
const root = await realpath(await mkdtemp("/tmp/rea-gsock-"));
const suffixBytes = Buffer.byteLength("/rea-ghidra-XXXXXX/bridge.sock");
let cleanupIncomplete = false;
const samples = [];
try {
for (const { proposedBytes, unicode } of [
{ proposedBytes: limit, unicode: false },
{ proposedBytes: limit + 1, unicode: false },
{ proposedBytes: limit + 2, unicode: false },
{ proposedBytes: limit + 1, unicode: true },
]) {
const character = unicode ? "雪" : "";
const padding =
proposedBytes -
suffixBytes -
Buffer.byteLength(root) -
1 -
Buffer.byteLength(character);
assert.ok(
padding > 0,
"Verifier workspace leaves no room for the boundary fixture",
);
const parent = join(root, "x".repeat(padding) + character);
await mkdir(parent);
assert.equal(Buffer.byteLength(parent) + suffixBytes, proposedBytes);
if (unicode) assert.ok(Buffer.byteLength(parent) > parent.length);
const sessionEnv = { ...env, TMPDIR: parent };
const client = new Client({
name: "rea-ghidra-socket-boundary",
version: "1",
});
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
env: sessionEnv,
stderr: "pipe",
});
let connected = false;
let opened = false;
let runtimeRoot;
let socketRoot;
let endpoint;
const cleanup = async (task) => {
try {
await task();
} catch (cause) {
cleanupIncomplete = true;
throw cause;
}
};
await withVerifierCleanup(async () => {
await client.connect(transport);
connected = true;
const tools = new Map(
(await client.listTools()).tools.map((tool) => [tool.name, tool]),
);
const call = async (name, arguments_ = {}) => {
const reply = await client.callTool(
{ name, arguments: arguments_ },
{ timeout: 180000, toolDefinition: tools.get(name) },
);
assert.notEqual(
reply.isError,
true,
`${name}: ${JSON.stringify(reply)}`,
);
return reply.structuredContent ?? JSON.parse(reply.content[0].text);
};
await call("open_binary", { path: target.path, provider_id: "ghidra" });
opened = true;
await call("list_documents");
const roots = (await readdir(parent)).filter((name) =>
name.startsWith("rea-ghidra-"),
);
assert.equal(roots.length, 1);
runtimeRoot = join(parent, roots[0]);
endpoint = JSON.parse(
await readFile(join(runtimeRoot, "ownership.json"), "utf8"),
).endpoint_path;
assert.equal(
Buffer.byteLength(join(runtimeRoot, "bridge.sock")),
proposedBytes,
);
const fallback = dirname(endpoint) !== runtimeRoot;
assert.equal(fallback, proposedBytes > limit);
if (fallback) {
socketRoot = dirname(endpoint);
assert.match(basename(socketRoot), /^rea-ghidra-socket-/u);
assert.equal((await stat(socketRoot)).mode & 0o777, 0o700);
}
samples.push({
proposed_bytes: proposedBytes,
unicode,
endpoint_bytes: Buffer.byteLength(endpoint),
fallback,
});
}, [
() =>
cleanup(async () => {
if (!connected || !opened) return;
const reply = await client.callTool(
{ name: "close_binary", arguments: {} },
{ timeout: 180000 },
);
assert.notEqual(
reply.isError,
true,
`close_binary: ${JSON.stringify(reply)}`,
);
}),
() =>
cleanup(async () => {
await client.close();
}),
() =>
cleanup(async () => {
await transport.close();
}),
]);
await assert.rejects(access(runtimeRoot), { code: "ENOENT" });
if (socketRoot !== undefined)
await assert.rejects(access(socketRoot), { code: "ENOENT" });
if (proposedBytes === limit + 1 && !unicode) {
const { stdout } = await exec(
process.execPath,
[
entrypoint,
"function",
target.path,
procedure,
"--provider",
"ghidra",
"--json",
],
{ env: sessionEnv, timeout: 240000, maxBuffer: 72 * 1024 * 1024 },
);
assert.ok(JSON.parse(stdout).normalized_result.procedure);
assert.deepEqual(
(await readdir(parent)).filter((name) =>
name.startsWith("rea-ghidra-"),
),
[],
"CLI left a Ghidra runtime directory",
);
}
}
assert.equal(
createHash("sha256")
.update(await readFile(target.path))
.digest("hex"),
before,
);
return {
status: "verified",
samples,
cli_boundary: true,
target_unchanged: true,
};
} finally {
// A failed cleanup retains its exact workspace for investigation and retry.
if (!cleanupIncomplete) await rm(root, { recursive: true, force: true });
}
}
+1 -6
View File
@@ -760,12 +760,7 @@ async function verifyCrossBlockProject(target, targetSha256) {
!all.matches.some((match) => match.address.startsWith("overlay_test:")),
);
assert.ok(
!all.searched.skipped_not_loaded.some(
(block) => block.segment === "overlay_test",
),
);
assert.ok(
all.searched.skipped_overlays.some(
all.searched.skipped_not_loaded.some(
(block) => block.segment === "overlay_test",
),
);
+13 -49
View File
@@ -637,37 +637,15 @@ const measureGhidra = async (path, functions) => {
return answers;
};
const describeFailure = (cause) => ({
message: cause instanceof Error ? cause.message : String(cause),
...(cause instanceof AggregateError
? { causes: cause.errors.map(describeFailure) }
: {}),
});
/** Inspect symbol-bearing caller binaries, retaining both selected and unmatched inputs. */
const targetPairs = async (root, names, selection) => {
/** Symbol-bearing caller binaries with the requested functions, beside their stripped copies. */
const targetPairs = async (root, names) => {
const pairs = [];
for (const flavor of (await readdir(root)).sort()) {
const directory = join(root, flavor);
if (!(await stat(directory)).isDirectory()) continue;
for (const file of (await readdir(directory)).sort()) {
if (file.endsWith(".stripped")) continue;
let symbols;
try {
symbols = await symbolAddresses(join(directory, file), names);
} catch (cause) {
selection.examined.push({
binary: `${flavor}/${file}`,
status: "inspection_failed",
error: describeFailure(cause),
});
throw cause;
}
selection.examined.push({
binary: `${flavor}/${file}`,
status: symbols.size > 0 ? "selected" : "no_matching_text_symbols",
matched_functions: [...symbols.keys()],
});
const symbols = await symbolAddresses(join(directory, file), names);
if (symbols.size > 0) pairs.push({ flavor, directory, file, symbols });
}
}
@@ -688,18 +666,6 @@ const verifyVariant = async ({ flavor, directory, symbols }, variant) => {
const results = [];
let timeoutRecovery;
const failures = [];
const callerTargetSelection =
values.targets === undefined
? undefined
: {
directory: values.targets,
functions_argument: values.functions ?? null,
requested_functions: (values.functions ?? "")
.split(",")
.filter(Boolean),
symbol_scan_complete: false,
examined: [],
};
let inspectionResourcesClean = true;
try {
const installation = await inspectKunaInstallation(
@@ -712,18 +678,13 @@ try {
);
kunaVersion = `kuna ${installation.value.version}`;
let callerPairs = [];
if (callerTargetSelection !== undefined) {
const names = callerTargetSelection.requested_functions;
if (values.targets !== undefined) {
const names = (values.functions ?? "").split(",").filter(Boolean);
assert.ok(names.length > 0, "--targets requires --functions");
callerPairs = await targetPairs(
callerTargetSelection.directory,
names,
callerTargetSelection,
);
callerTargetSelection.symbol_scan_complete = true;
callerPairs = await targetPairs(values.targets, names);
assert.ok(
callerPairs.length > 0,
`No caller-built targets contain the requested functions in ${callerTargetSelection.directory}`,
`No caller-built targets contain the requested functions in ${values.targets}`,
);
const found = new Set(
callerPairs.flatMap(({ symbols }) => [...symbols.keys()]),
@@ -811,15 +772,18 @@ if (cleanupComplete)
cleanupComplete = false;
failures.push(cause);
}
const describeFailure = (cause) => ({
message: cause instanceof Error ? cause.message : String(cause),
...(cause instanceof AggregateError
? { causes: cause.errors.map(describeFailure) }
: {}),
});
const report = {
lane: "kuna",
status: failures.length === 0 ? "passed" : "failed",
kuna_version: kunaVersion,
platform: `${process.platform}/${process.arch}`,
verifier_run: lifecycle,
...(callerTargetSelection === undefined
? {}
: { caller_target_selection: callerTargetSelection }),
results,
...(timeoutRecovery === undefined
? {}
+2
View File
@@ -233,6 +233,7 @@ it.each([true, false])(
{ timeoutMs: 100, maxOutputBytes: 1024, volatileOutput },
undefined,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected a capture allowance refusal");
const projected = projectAnalysisError(result.error);
expect(projected.retryable).toBe(volatileOutput);
@@ -262,6 +263,7 @@ it("reports the effective fixed adb deadline", async () => {
{ timeoutMs: 600_000, maxOutputBytes: 1024 },
undefined,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected an adb deadline");
const projected = projectAnalysisError(result.error);
expect(projected).toMatchObject({
+7
View File
@@ -87,6 +87,7 @@ const refusedBy = async (
request: AdbRequest,
): Promise<ReturnType<typeof projectAnalysisError>> => {
const result = await provider.execute(request);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected a refusal");
return projectAnalysisError(result.error);
};
@@ -576,6 +577,7 @@ it.each([
await expect(lstat(packageDirectory)).rejects.toThrow();
return;
}
expect(result.ok).toBe(true);
if (!result.ok) throw new Error(result.error.message);
expect(
(
@@ -1461,6 +1463,7 @@ exit 1`,
overwrite: false,
},
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected snapshot cleanup to be reported");
expect(result.error.cleanupResources).toHaveLength(1);
expect(result.error.partialObservation).toMatchObject({
@@ -1520,6 +1523,7 @@ exit 1`,
overwrite: false,
},
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected a changed-artifact result");
const evidence = parseEvidence(result.error.partialObservation);
expect(evidence).toMatchObject({
@@ -1864,6 +1868,7 @@ it("reads Android 16 focus declarations from displays when windows omits them",
operation: "inspect_adb_window",
input: { serial: "emulator-5554" },
});
expect(execution.ok).toBe(true);
if (!execution.ok) throw execution.error;
expect(execution.value.limitations).toContainEqual(
expect.stringContaining("dumpsys window displays"),
@@ -1886,6 +1891,7 @@ it("describes missing window focus as unknown after both sections omit declarati
operation: "inspect_adb_window",
input: { serial: "emulator-5554" },
});
expect(execution.ok).toBe(true);
if (!execution.ok) throw execution.error;
expect(execution.value.limitations).toContainEqual(
expect.stringContaining("focus is unknown"),
@@ -1909,6 +1915,7 @@ it.each(["windows", "displays"])(
operation: "inspect_adb_window",
input: { serial: "emulator-5554" },
});
expect(execution.ok).toBe(true);
if (!execution.ok) throw execution.error;
expect(execution.value.result).toMatchObject({
focused_window: null,
+2
View File
@@ -114,6 +114,7 @@ it("applies the omitted 600000 ms decode deadline and a selected value on the ne
expect(outputBudgets).toEqual([4 * 1024 * 1024]);
const timedOut = await decode(provider, path, 200);
expect(timedOut.ok).toBe(false);
if (timedOut.ok) throw new Error("Expected a decode deadline");
const projected = projectAnalysisError(timedOut.error);
expect(projected).toMatchObject({
@@ -143,6 +144,7 @@ it("applies the omitted 600000 ms decode deadline and a selected value on the ne
]);
const maximum = await decode(provider, path, MAX_APKTOOL_DECODE_TIMEOUT_MS);
expect(maximum.ok).toBe(false);
if (maximum.ok) throw new Error("Expected the maximum decode deadline");
expect(projectAnalysisError(maximum.error).remediation.action).toContain(
"do not increase it further",
+2
View File
@@ -97,6 +97,7 @@ const refusedBy = async (
request: ApktoolRequest,
): Promise<ReturnType<typeof projectAnalysisError>> => {
const result = await provider.execute(request);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected a refusal");
return projectAnalysisError(result.error);
};
@@ -582,6 +583,7 @@ it("still refuses invalid UTF-8 bytes in decoded strings", async () => {
operation: "decode_android_resources",
input: { path: apk.path, include_strings: true },
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected a refusal");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "unreadable_output",
@@ -1,6 +1,7 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { parseOpenStep } from "plist";
import { expect, it } from "vitest";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
@@ -18,6 +19,7 @@ it.each([
const executable = join(programs, "Fixture");
const infoPlist = join(bundle, "Contents", "Info.plist");
const source = `${prefix}{ CFBundleExecutable = Fixture; }`;
expect(parseOpenStep(source)).toEqual({ CFBundleExecutable: "Fixture" });
await mkdir(programs, { recursive: true });
await writeFile(infoPlist, source);
await writeFile(executable, "fixture - never executed");
@@ -286,6 +286,7 @@ it("retains completed bundle facts as bundle Evidence after cancellation", async
total_analysis_timeout_ms: 1000,
});
const result = await analyzeWebBundle(browser, selected);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancellation");
expect(result.error).toBe(failure);
expect(result.error.cause).toBe(reason);
@@ -312,6 +313,7 @@ it("preserves a prior caller reason without admitting browser work", async () =>
input,
{ signal: AbortSignal.abort(reason) },
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancelled inventory");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(reason);
@@ -48,6 +48,7 @@ const expectRetainedCapture = (
result: Awaited<ReturnType<typeof runScenario>>,
failure: AnalysisError,
) => {
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the retained capture");
expect(result.error).toBe(failure);
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
@@ -196,6 +197,7 @@ it("rejects a successful capture that is not a completed scenario", async () =>
captureScenario: async () => ok({} as BrowserScenarioCapture),
};
const result = await captureBrowserScenario(provider, scenario);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected a completed-capture refusal");
expect(result.error).toBeInstanceOf(AnalysisOutputError);
expect(result.error.message).toContain(
+1
View File
@@ -28,6 +28,7 @@ describe("call-path traversal cancellation ownership", () => {
input,
controller.signal,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancellation");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
@@ -59,6 +59,7 @@ describe("JSON client configuration repeated-member edits", () => {
removeShadowedMembersAt: [path],
});
expect(result.ok).toBe(true);
if (!result.ok) throw new Error("authorized repeats were refused");
expect(parseStrictJson(result.value)).toEqual({
mcpServers: {
@@ -108,6 +109,7 @@ describe("JSON client configuration repeated-member edits", () => {
"handles %s without losing peers",
(_label, text, expectedServers, authority) => {
const result = removeRea(text, "json", authority);
expect(result.ok).toBe(true);
if (!result.ok) throw new Error("non-shadowed removal was refused");
expect(parseStrictJson(result.value)).toEqual({
mcpServers: expectedServers,
@@ -125,6 +127,7 @@ describe("JSON client configuration repeated-member edits", () => {
removeShadowedMembersAt: [["mcpServers", "rea"]],
});
expect(result.ok).toBe(true);
if (!result.ok) throw new Error("authorized escaped repeat was refused");
expect(result.value).toContain(`"mcpServers":${inactive}`);
expect(parseStrictJson(result.value)).toEqual({
@@ -143,6 +146,7 @@ describe("JSON client configuration repeated-member edits", () => {
removeShadowedMembersAt: [["mcp", "rea"]],
});
expect(result.ok).toBe(true);
if (!result.ok) throw new Error("authorized JSONC repeats were refused");
expect(result.value).toContain("// top comment");
expect(result.value).toContain("// first");
@@ -42,6 +42,7 @@ for (const format of ["toml", "grok"] as const)
format,
source,
);
expect(result.ok).toBe(true);
if (!result.ok) throw new Error("TOML edit was refused");
for (const comment of [
"# server header note",
@@ -84,6 +85,7 @@ it("retains comments from dotted REA assignments and nested inline values", () =
"toml",
source,
);
expect(result.ok).toBe(true);
if (!result.ok) throw new Error("TOML removal was refused");
expect(result.value).toContain(
source.includes("# dotted note")
+1
View File
@@ -102,6 +102,7 @@ type DirectAnalysisTool =
| "address_to_file_offset"
| "binary_overview"
| "procedure_pseudo_code"
| "procedure_references"
| "read_function_instructions"
| "inspect_native_instruction"
| "inspect_native_data_type"
@@ -6,6 +6,7 @@ it("preserves field diagnostics for rejected enhanced tool inputs", async () =>
const execute = vi.fn();
const tools = new EnhancedTools({ execute });
const result = await tools.execute("trace_native_values", { procedure: "" });
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected an input failure");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "invalid_request",
@@ -17,6 +18,7 @@ it("preserves field diagnostics for rejected enhanced tool inputs", async () =>
expect(execute).not.toHaveBeenCalled();
const sibling = await tools.execute("batch_decompile", { addresses: "bad" });
expect(sibling.ok).toBe(false);
if (sibling.ok) throw new Error("expected an input failure");
expect(projectAnalysisError(sibling.error)).toMatchObject({
details: { issues: [{ path: ["addresses"], reason: "invalid_type" }] },
@@ -444,6 +444,7 @@ describe("EnhancedTools cancellation ownership", () => {
controller.signal,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Pre-cancelled observation was accepted");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
@@ -212,6 +212,7 @@ it("projects completed raw capture facts on failure through the caller's selecti
record_ordinals: [0],
sensitive_values: ["private-marker"],
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Cancellation must remain the primary result");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(reason);
@@ -269,6 +270,7 @@ it("keeps a redacted cleanup error linked to its live owner through service clos
format: "har",
sensitive_values: ["private-marker"],
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Retained cleanup must remain a failure");
const returnedError = result.error;
expect(returnedError.cleanupIncomplete).toBe(true);
+1
View File
@@ -703,6 +703,7 @@ it("reports a retained workflow and the committed prefix when Unknown publicatio
? err(failure)
: records.recordUnknown(input),
});
expect(outcome.ok).toBe(false);
if (outcome.ok) throw new Error("expected failure");
const projected = projectAnalysisError(outcome.error);
expect(projected.details?.workflow_unknown_publication).toEqual({
@@ -53,6 +53,7 @@ it.each([
it("rejects an unknown operation before any effect", async () => {
const service = new AdbDeviceAnalysisService(refusedPort);
const result = await service.execute("unknown_adb_operation" as never, {});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected an unknown operation refusal");
expect(result.error).toBeInstanceOf(AnalysisInputError);
if (!(result.error instanceof AnalysisInputError)) throw result.error;
@@ -211,6 +211,7 @@ it("keeps native JSON source excerpts internal while preserving the failure caus
const resources = new ArtifactResourceScope();
try {
const result = await inspectApiContractEvidence({ path }, resources);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected a syntax refusal");
expect(result.error.cause).toBeInstanceOf(SyntaxError);
expect(JSON.stringify(result.error)).not.toContain("u:p@h");
@@ -256,6 +257,7 @@ it.each([
});
await new Promise<void>((resolve) => setImmediate(resolve));
expect(controller.signal.aborted).toBe(true);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the parser failure");
expect(result.error._tag).toBe(tag);
expect(result.error.partialObservation).toEqual({
@@ -84,6 +84,7 @@ it.each(["path", "operations_path"] as const)(
);
await withScope(async (resources) => {
const result = await inspectApiContractEvidence(input, resources);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected invalid UTF-8");
expect(result.error._tag).toBe("AnalysisInputError");
expect(projectAnalysisError(result.error)).toMatchObject({
@@ -147,6 +148,7 @@ it("attributes auxiliary file admission failures to operations_path and retains
{ ...input, operations_path: join(input.path, "missing") },
resources,
);
expect(missing.ok).toBe(false);
if (missing.ok) throw new Error("Expected missing operation file");
expect(projectAnalysisError(missing.error)).toMatchObject({
details: { issues: [{ path: ["operations_path"] }] },
@@ -394,6 +394,7 @@ it.each([false, true])(
releaseClose();
const result = await opening;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected failed resource switch");
expect(result.error.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cleanupPrimary?.cause).toBe(reason);
@@ -479,6 +480,7 @@ it("keeps an earlier resource close failure primary when a later cleanup is canc
releaseCandidateClose();
const result = await opening;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected failed resource switch");
expect(result.error).toBe(originalFailure);
expect(result.error.cleanupPrimary).toBeUndefined();
@@ -567,6 +569,7 @@ it("keeps cancellation primary when binary retirement fails before opening resou
releaseClose();
const result = await opening;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected failed resource open");
expect(result.error.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cleanupPrimary?.cause).toBe(reason);
@@ -658,6 +661,7 @@ it("keeps an earlier resource close failure primary when binary candidate cleanu
releaseCandidateClose();
const result = await opening;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected failed binary open");
expect(result.selection).toEqual({ kind: "none" });
expect(result.error).toBe(originalFailure);
@@ -761,6 +765,7 @@ it("keeps an earlier binary close failure primary when candidate cleanup is canc
releaseCandidateClose();
const result = await opening;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected failed resource open");
expect(result.error).toBe(originalFailure);
expect(result.error.cleanupPrimary).toBeUndefined();
@@ -861,6 +866,7 @@ it("cancels a binary switch during resource retirement and retains null-subject
releaseResourceClose();
const result = await opening;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancelled binary open");
expect(result.error.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cleanupPrimary?.cause).toBe(reason);
@@ -97,6 +97,7 @@ it("reports the first mixed child failure before awaiting the other owner and re
await Promise.all([nativeStarted, failureObserved]);
releaseNativeClose();
const first = await closing;
expect(first.ok).toBe(false);
if (first.ok) throw new Error("Expected mixed close failure");
expect(first.error.cleanupIncomplete).toBe(true);
@@ -113,6 +113,7 @@ it("retains every failed child resource across composite cleanup retries", async
for (let attempt = 0; attempt < 2; attempt += 1) {
const closed = await client.close();
expect(closed.ok).toBe(false);
if (closed.ok) throw new Error("Expected composite cleanup failure");
expect(closed.error).toMatchObject({
cleanupIncomplete: true,
@@ -157,6 +158,7 @@ it("preserves one child's typed cleanup error unchanged", async () => {
const closed = await client.close();
expect(closed.ok).toBe(false);
if (closed.ok) throw new Error("Expected child cleanup failure");
expect(closed.error).toBe(child.cleanupError);
});
@@ -304,6 +306,7 @@ it.each([1, 2])(
release();
}
const first = await closing;
expect(first.ok).toBe(false);
if (first.ok) throw new Error("Expected retained child cleanup");
expect(notifications).toEqual([
...children.map((child) => child.cleanupError),
@@ -315,6 +318,7 @@ it.each([1, 2])(
notifications.length = 0;
const retried = await closeAnalysisClient(client, "composite", options);
expect(retried.ok).toBe(false);
if (retried.ok) throw new Error("Expected failed cleanup retry");
expect(notifications).toEqual([
...children.map((child) => child.cleanupError),
@@ -126,6 +126,7 @@ it.each(["288", "309 "])(
identity,
inspect: () => Promise.resolve(ok(malformed)),
}).inspect({ path: report.artifact.path });
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected unsupported provider crash kind");
expect(result.error._tag).toBe("AnalysisOutputError");
},
@@ -169,6 +169,7 @@ it.each([
},
});
const result = await service.inspect(null, { signal: controller.signal });
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected cancelled admission");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
@@ -90,6 +90,7 @@ it("retains provider metadata in a late-cancellation observation", async () => {
const result = await service.inspect(input, { signal: controller.signal });
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected late cancellation");
expect(result.error).toMatchObject({ _tag: "AnalysisCancelledError" });
expect(result.error.cause).toBe(reason);
@@ -254,6 +254,7 @@ it.each([
},
});
const result = await service.inspect(null, { signal: controller.signal });
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected cancelled admission");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
@@ -171,6 +171,7 @@ it("preserves source Evidence and requested output choices when publication fail
path: "/output/seeds.tsv",
base: "0x2000",
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Failed publication accepted");
expect(result.error).toBe(failure);
expect(result.error.partialObservation).toMatchObject({
@@ -212,6 +213,7 @@ it("retains a published seed receipt if cancellation arrives after the atomic wr
{ path: receipt.path },
{ signal: controller.signal },
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Cancellation ignored");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
@@ -449,6 +449,7 @@ describe("residual unknown registry resolution and history", () => {
).toBe(true);
const before = serializeEvidenceBundle(base.export());
const conflict = base.import(branch.export());
expect(conflict.ok).toBe(false);
if (conflict.ok) throw new Error("Expected divergent history refusal");
expectHistoryConflict(conflict.error, created.value.unknown_id);
expect(base.get(source.evidence_id)?.caller_input_path).toBe("./original");
@@ -32,6 +32,7 @@ it.each([
withEvidenceSources(cancelled ? err(primary) : ok("complete"), [source]),
sourceOwner === undefined ? undefined : { sourceOwner },
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected recording failure");
expect(result.error).toBe(cancelled ? primary : failure);
expect(primary.secondaryFailures).toEqual(cancelled ? [failure] : []);
@@ -75,6 +75,7 @@ it("preserves failed Inspector cleanup when cancellation occurs during observati
{ signal: controller.signal },
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected failed cleanup");
expect(result.error).toBe(failure);
expect(result.error.cleanupIncomplete).toBe(true);
@@ -4,7 +4,7 @@ import { expect, it } from "vitest";
import { graphForJavaScript as graphFor } from "../../../tests/fixtures/javascriptSemanticGraph.js";
import { createJavaScriptSemanticGraphProjection } from "./JavaScriptSemanticGraphBuilder.js";
import { queryJavaScriptSemanticGraph } from "../../domain/javascript/javascriptSemanticQuery.js";
import { resolveJavaScriptSemanticEvidence } from "../../../tests/support/javascriptSemanticGraphFixture.js";
import { resolveJavaScriptSemanticEvidence } from "../../domain/javascript/javascriptSemanticGraph.js";
const SHA256 = "a".repeat(64);
const GRAPH_ID = `jag_${"b".repeat(64)}`;
@@ -74,6 +74,7 @@ it("retains transitive provenance without treating dependencies as native observ
undefined,
(id) => (id === dependency.evidence_id ? dependency : undefined),
);
expect(missing.ok).toBe(false);
if (missing.ok) throw new Error("Missing ancestor accepted");
expect(missing.error).toMatchObject({
evidenceId: ancestor.evidence_id,
@@ -932,6 +933,7 @@ it("enforces the caller-resolved representation allowance and names its setting"
const lowered = await matchNativeFunctions(small, {
representationAllowance: { maximumBytes: 64 * 1024, source: "runtime" },
});
expect(lowered.ok).toBe(false);
if (lowered.ok) throw new Error("Lowered allowance admitted the request");
expect(lowered.error).toMatchObject({
_tag: "AnalysisResourceConstraintError",
@@ -164,6 +164,7 @@ describe("native value trace seed admission", () => {
},
{ procedure: "missing" },
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected seed failure");
expect(result.error).toBe(failure);
expect(attempted).toEqual([["analyze_function", { procedure: "missing" }]]);
@@ -122,6 +122,7 @@ it("retains completed pyc Evidence and the opaque caller reason on late cancella
{ path: report.artifact.path },
{ signal: controller.signal },
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancellation");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
@@ -218,6 +218,7 @@ it.each([
},
});
const result = await service.inspect(null, { signal: controller.signal });
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected cancelled admission");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
@@ -83,6 +83,7 @@ it("retains provider metadata in a late-cancellation observation", async () => {
const result = await service.decode(input, { signal: controller.signal });
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected late cancellation");
expect(result.error).toMatchObject({ _tag: "AnalysisCancelledError" });
expect(result.error.cause).toBe(reason);
@@ -290,6 +290,7 @@ it("refuses two admitted inventories that together exceed the comparison cap", a
right: evidence(right),
scope: "both",
});
expect(combined.ok).toBe(false);
if (combined.ok) throw new Error("Expected comparison allowance refusal");
const projected = projectAnalysisError(combined.error);
expect(projected.retryable).toBe(false);
+2 -2
View File
@@ -84,7 +84,7 @@ it("stops a close hook even when retained cleanup is still failing", async () =>
(cause: unknown) => cause,
);
expect(closeFailure).toMatchObject({
message: expect.stringContaining("stuck lease, close hook"),
message: "Artifact cleanup remains incomplete: stuck lease, close hook",
failedOwners: [owner],
cleanup: {
reason: "still owned; hook still stopping",
@@ -305,7 +305,7 @@ it("retains the same failed reader owner in a retry failure and retries before a
await expect(scope.run(async () => "must not run")).rejects.toMatchObject({
reason: "unavailable",
message: expect.stringContaining("fixture reader"),
message: "Artifact cleanup remains incomplete: fixture reader",
failedOwners: [owner],
});
expect(closeAttempts).toBe(2);
@@ -72,6 +72,7 @@ it.skipIf(process.platform === "win32")(
max_entries: 32,
max_probe_bytes: 4096,
});
expect(invalid.ok).toBe(false);
if (invalid.ok) throw new Error("expected ENOTDIR refusal");
expect(projectAnalysisError(invalid.error)).toMatchObject({
code: "target_unavailable",
@@ -82,6 +83,7 @@ it.skipIf(process.platform === "win32")(
max_entries: 32,
max_probe_bytes: 4096,
});
expect(valid.ok).toBe(true);
if (!valid.ok) throw valid.error;
expect(
desktopApplicationSchema
+1 -1
View File
@@ -295,7 +295,7 @@ it("fails /images followed by a malformed structure, and leaves a plain DTB a fi
scanCanonicalArtifactInventory(await writeFit("broken.itb", bytes)),
).rejects.toMatchObject({
reason: "format",
message: expect.stringMatching(/FDT.*token 10/iu),
message: "FIT is malformed: Unknown FDT token 10",
});
const plain = await scanCanonicalArtifactInventory(
await writeFit("board.dtb", devicetreeFixture()),
+1 -1
View File
@@ -445,7 +445,7 @@ describe("GodotPckArtifactReader carrier truncation", () => {
try {
await expect(text(await reader.open(entry))).rejects.toMatchObject({
reason: "integrity",
message: expect.stringContaining("member.bin"),
message: "Godot PCK entry ended early: member.bin",
});
expect(calls).toBe(2);
} finally {
+4 -3
View File
@@ -308,7 +308,8 @@ it.each([false, true])(
if (sea)
await expect(located).rejects.toMatchObject({
reason: "limit",
message: expect.stringMatching(/PE.*512 MiB/iu),
message:
"Node SEA PE inspection exceeds the 512 MiB PE parser input ceiling",
});
else await expect(located).resolves.toBeUndefined();
} finally {
@@ -658,7 +659,7 @@ it("rejects missing Mach-O load commands after a confirmed SEA section", async (
scanCanonicalArtifactInventory(await writeCarrier("commands", bytes)),
).rejects.toMatchObject({
reason: "format",
message: expect.stringMatching(/Mach-O.*load commands.*truncated/iu),
message: "Node SEA Mach-O load commands are truncated",
});
});
@@ -707,7 +708,7 @@ it("reports a member that ends after the carrier recheck as an integrity failure
try {
await expect(buffer(await reader.open(entry))).rejects.toMatchObject({
reason: "integrity",
message: expect.stringContaining("sea.js"),
message: "Node SEA entry ended early: sea.js",
});
expect(calls).toBe(2);
} finally {
@@ -558,7 +558,8 @@ it.each(["reported carutil", "tab-indented carutil", "observed assetutil"])(
expect(error.cause).toBe(cause);
expect(projectAnalysisError(error)).toMatchObject({
code: "artifact_operation_failed",
message: expect.stringMatching(/invalid.*changed.*fresh copy/iu),
message:
"Artifact is invalid or has changed. Get a fresh copy and try again.",
details: {
operation: "inspect_asset_catalog",
reason: "format",
@@ -8,8 +8,10 @@ import { describe, expect, it } from "vitest";
import { analyzeInterfaceBuilderBundle } from "./InterfaceBuilderAnalysis.js";
import { interfaceBuilderDecodeBudgetFailure } from "./InterfaceBuilderDecodeBudget.js";
import { preflightPropertyListDecode } from "./PropertyListDecodeBudget.js";
import { unknownDateLimitation } from "../../domain/apple/plistValue.js";
import { parseXmlPropertyList } from "../../domain/propertyListKeys.js";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import { scanBinaryPlistScalars } from "./PlistScalars.js";
import {
readBinaryPlistObjectOffset,
readBinaryPlistObjectTableLayout,
@@ -179,13 +181,8 @@ it("retains explicit date uncertainty for an overlapping binary plist object", a
"com.apple.ibtool.document.classes": {},
}),
);
// The fixture date is 978307200 seconds before the 2001 plist epoch.
const encodedDate = Buffer.alloc(9);
encodedDate[0] = 0x33;
encodedDate.writeDoubleBE(-978307200, 1);
const dateOffset = bytes.indexOf(encodedDate);
expect(dateOffset).toBeGreaterThanOrEqual(0);
expect(bytes.indexOf(encodedDate, dateOffset + 1)).toBe(-1);
const date = scanBinaryPlistScalars(bytes).dates[0];
if (date === undefined) throw new Error("fixture lost its date");
const table = readBinaryPlistObjectTableLayout(bytes);
let dataObjectId: number | undefined;
for (let objectId = 0; objectId < table.objectCount; objectId += 1) {
@@ -195,11 +192,11 @@ it("retains explicit date uncertainty for an overlapping binary plist object", a
if (dataObjectId === undefined)
throw new Error("fixture lost its empty data");
// Keep both objects reachable through a recognized archive while sharing source bytes.
bytes.writeDoubleBE(0.0005, dateOffset + 1);
bytes[dateOffset + 2] = 0x40;
writeBinaryPlistObjectOffset(bytes, table, dataObjectId, dateOffset + 2);
bytes.writeDoubleBE(0.0005, date.offset + 1);
bytes[date.offset + 2] = 0x40;
writeBinaryPlistObjectOffset(bytes, table, dataObjectId, date.offset + 2);
expect(readBinaryPlistObjectOffset(bytes, table, dataObjectId)).toBe(
dateOffset + 2,
date.offset + 2,
);
expect(
preflightPropertyListDecode(bytes, 1024 * 1024, undefined, {
@@ -207,7 +204,7 @@ it("retains explicit date uncertainty for an overlapping binary plist object", a
}),
).toMatchObject({
datePrecisionOverlapCount: 1,
reachableDateOffsets: [dateOffset],
reachableDateOffsets: [date.offset],
});
await writeFile(join(bundle, "Panel.nib"), bytes);
@@ -309,10 +306,8 @@ describe("binary Interface Builder plist dates", () => {
const offset = submillisecond.indexOf(marker);
submillisecond.writeDoubleBE(0.1005, offset + 1);
const unknown = await nib(submillisecond, "subms");
expect(unknown.limitations).toContainEqual(
expect.stringMatching(
/Panel\.nib.*\b1 date value.*decoder could not represent.*timestamps/iu,
),
expect(unknown.limitations).toContain(
`Panel.nib: ${unknownDateLimitation(1)}`,
);
expect(JSON.stringify(unknown)).not.toContain("2001-01-01T00:00:00.100Z");
@@ -95,6 +95,7 @@ it.skipIf(isRoot)(
format: "plist",
})
.execute("inspect_keyed_archive", {});
expect(executed.ok).toBe(false);
if (executed.ok) throw new Error("expected a denied read");
expect(executed.error).toBeInstanceOf(AnalysisAccessDeniedError);
expect(projectAnalysisError(executed.error)).toMatchObject({
+20 -40
View File
@@ -10,9 +10,13 @@ import {
decodeKeyedArchiveBytes,
inspectBundleKeyedArchive,
} from "./KeyedArchiveReader.js";
import { PlistNestingLimitError } from "../../domain/apple/plistValue.js";
import {
PlistNestingLimitError,
unknownDateLimitation,
} from "../../domain/apple/plistValue.js";
import { deeplyNestedXmlPlist } from "../../../tests/fixtures/keyedArchiveXmlNesting.js";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import { scanBinaryPlistScalars } from "./PlistScalars.js";
import { embedSubmillisecondDateInWideIntegerPayload } from "../../../tests/fixtures/binaryPlistScalarAlias.js";
import {
readBinaryPlistObjectOffset,
@@ -49,17 +53,6 @@ const binaryArchiveWithDate = (): Buffer =>
}),
);
// Locate the known binary date independently of the scanner under test.
const fixtureDateOffset = (bytes: Buffer, seconds: number): number => {
const encoded = Buffer.alloc(9);
encoded[0] = 0x33;
encoded.writeDoubleBE(seconds, 1);
const offset = bytes.indexOf(encoded);
if (offset < 0 || bytes.indexOf(encoded, offset + 1) !== -1)
throw new Error("binary archive fixture must contain exactly one date");
return offset;
};
const binaryArchiveWithOverlappingDate = (): Buffer => {
const bytes = Buffer.from(
buildBinary({
@@ -72,17 +65,19 @@ const binaryArchiveWithOverlappingDate = (): Buffer => {
$top: { root: { UID: 1 } },
}),
);
const dateOffset = fixtureDateOffset(bytes, 0);
bytes.writeDoubleBE(0.0005, dateOffset + 1);
const date = scanBinaryPlistScalars(bytes).dates[0];
if (date === undefined)
throw new Error("binary archive fixture lost its date");
bytes.writeDoubleBE(0.0005, date.payloadOffset);
const objectTable = readBinaryPlistObjectTableLayout(bytes);
for (let objectId = 0; objectId < objectTable.objectCount; objectId += 1) {
const offset = readBinaryPlistObjectOffset(bytes, objectTable, objectId);
if (offset !== dateOffset && bytes[offset] === 0x40) {
if (offset !== date.offset && bytes[offset] === 0x40) {
writeBinaryPlistObjectOffset(
bytes,
objectTable,
objectId,
dateOffset + 2,
date.payloadOffset + 1,
);
return bytes;
}
@@ -92,12 +87,14 @@ const binaryArchiveWithOverlappingDate = (): Buffer => {
const binaryArchiveDateCrossingOffsetTable = (): Buffer => {
const bytes = Buffer.from(binaryArchiveWithDate());
const dateOffset = fixtureDateOffset(bytes, 0.1);
const date = scanBinaryPlistScalars(bytes).dates[0];
if (date === undefined)
throw new Error("binary archive fixture lost its date");
const objectTable = readBinaryPlistObjectTableLayout(bytes);
let dateObjectId = -1;
for (let objectId = 0; objectId < objectTable.objectCount; objectId += 1) {
if (
readBinaryPlistObjectOffset(bytes, objectTable, objectId) === dateOffset
readBinaryPlistObjectOffset(bytes, objectTable, objectId) === date.offset
)
dateObjectId = objectId;
}
@@ -588,9 +585,6 @@ const dateScalar = (iso8601: string | null) => ({
value: { $plist_type: "date", iso8601 },
});
const oneUnknownDate = /\b1 date value.*could not represent.*timestamps/iu;
const twoUnknownDates = /\b2 date value.*could not represent.*timestamps/iu;
describe("binary keyed archive dates", () => {
const selection = { offset: 0, limit: 10 };
@@ -600,11 +594,7 @@ describe("binary keyed archive dates", () => {
expect(graph.objects[1]).toMatchObject(
dateScalar("2001-01-01T00:00:00.100Z"),
);
expect(graph.limitations).not.toContainEqual(
expect.stringMatching(
/date value.*decoder could not represent.*timestamps/iu,
),
);
expect(graph.limitations).not.toContain(unknownDateLimitation(1));
expect(Buffer.compare(bytes, pythonBinaryDateControl)).toBe(0);
expect(createHash("sha256").update(bytes).digest("hex")).toBe(
"39f08db8341bd74220352dfd559485515cce1e6035e08f27a59ab8f856374a30",
@@ -619,11 +609,7 @@ describe("binary keyed archive dates", () => {
expect(inspected.objects[1]).toMatchObject(
dateScalar("2001-01-01T00:00:00.100Z"),
);
expect(inspected.limitations).not.toContainEqual(
expect.stringMatching(
/date value.*decoder could not represent.*timestamps/iu,
),
);
expect(inspected.limitations).not.toContain(unknownDateLimitation(1));
expect(
Buffer.compare(await readFile(join(root, "archive.plist")), bytes),
).toBe(0);
@@ -678,9 +664,7 @@ describe("binary keyed archive dates", () => {
expect(JSON.stringify(graph.objects[1]?.value)).not.toContain(
"2001-01-01T00:00:00.100Z",
);
expect(graph.limitations).toContainEqual(
expect.stringMatching(oneUnknownDate),
);
expect(graph.limitations).toContain(unknownDateLimitation(1));
expect(graph.limitations).not.toContainEqual(
expect.stringMatching(/number metadata.*could not.*observed/iu),
);
@@ -696,9 +680,7 @@ describe("binary keyed archive dates", () => {
parameters: { path: "archive.plist" },
});
expect(inspected.objects[1]).toMatchObject(dateScalar(null));
expect(inspected.limitations).toContainEqual(
expect.stringMatching(oneUnknownDate),
);
expect(inspected.limitations).toContain(unknownDateLimitation(1));
expect(
Buffer.compare(
await readFile(join(root, "archive.plist")),
@@ -738,9 +720,7 @@ describe("binary keyed archive dates", () => {
{ $plist_type: "date", iso8601: null },
{ $plist_type: "date", iso8601: null },
]);
expect(mixedGraph.limitations).toContainEqual(
expect.stringMatching(twoUnknownDates),
);
expect(mixedGraph.limitations).toContain(unknownDateLimitation(2));
expect(Buffer.compare(mixed, mixedOriginal)).toBe(0);
});
});
@@ -352,9 +352,7 @@ describe("Mach-O encryption reader: segment mapping", () => {
range: {
mapping: {
status: "unknown",
reason: expect.stringMatching(
/load command 0\b.*LC_SEGMENT_64.*appears.*32-bit/iu,
),
reason: "load command 0 LC_SEGMENT_64 appears in a 32-bit image",
},
},
});
@@ -488,9 +486,7 @@ describe("Mach-O encryption reader: defects are never absent", () => {
wide: false,
commands: [encryption({ cryptoff: 0, cryptsize: 0, cryptid: 0 })],
}),
reason: expect.stringMatching(
/load command 0\b.*LC_ENCRYPTION_INFO_64.*appears.*32-bit/iu,
),
reason: "load command 0 LC_ENCRYPTION_INFO_64 appears in a 32-bit image",
},
{
name: "an unexpected cmdsize",
@@ -500,9 +496,8 @@ describe("Mach-O encryption reader: defects are never absent", () => {
encryption({ cryptoff: 0, cryptsize: 0, cryptid: 0 }, true, 32),
],
}),
reason: expect.stringMatching(
/load command 0\b.*LC_ENCRYPTION_INFO_64.*cmdsize 32\b.*expected 24\b/iu,
),
reason:
"load command 0 LC_ENCRYPTION_INFO_64 has cmdsize 32, expected 24",
},
{
name: "a range beyond the slice",
@@ -674,15 +669,12 @@ describe("Mach-O encryption reader: universal files", () => {
},
{
status: "malformed",
reason: expect.stringMatching(
/FAT architecture 1\b.*CPU type\/subtype.*disagree.*Mach-O header/iu,
),
reason:
"FAT architecture 1 CPU type/subtype disagrees with its Mach-O header",
},
{
status: "malformed",
reason: expect.stringMatching(
/FAT architecture 2\b.*slice range.*beyond.*file/iu,
),
reason: "FAT architecture 2 slice range extends beyond the file",
},
{ status: "encrypted", reason: null },
]);
@@ -753,9 +745,7 @@ describe("Mach-O encryption reader: universal files", () => {
{
cpu_type: CPU.arm64.type,
status: "malformed",
reason: expect.stringMatching(
/FAT architecture 1\b.*slice range.*beyond.*file/iu,
),
reason: "FAT architecture 1 slice range extends beyond the file",
commands: [],
},
],
@@ -804,9 +794,7 @@ describe("Mach-O encryption reader: large universal files", () => {
{
container_file_offset: size,
status: "malformed",
reason: expect.stringMatching(
/FAT architecture 1\b.*slice range.*beyond.*file/iu,
),
reason: "FAT architecture 1 slice range extends beyond the file",
},
],
});
@@ -814,9 +802,7 @@ describe("Mach-O encryption reader: large universal files", () => {
// unresolved JVM identity.
expect(await readMachoImage(sparseReadAt, size)).toEqual({
status: "malformed",
reason: expect.stringMatching(
/FAT architecture 1\b.*slice range.*beyond.*file/iu,
),
reason: "FAT architecture 1 slice range extends beyond the file",
});
});
});
@@ -923,9 +909,8 @@ describe("Mach-O encryption reader: mapping budget", () => {
]);
expect(facts.slices[1]?.range?.mapping).toEqual({
status: "unknown",
reason: expect.stringMatching(
/mapping.*more than.*5 segment and section records.*slices/iu,
),
reason:
"mapping needs more than the 5 segment and section records shared by all slices",
});
});
});
@@ -1889,9 +1889,7 @@ describe("Mach-O load command reader failures", () => {
view.setUint32(4, 8, true);
expect(await read(machoImage({ commands: [short] }))).toMatchObject({
status: "malformed",
reason: expect.stringMatching(
/load command 0\b.*too short.*string offset/iu,
),
reason: "load command 0 is too short to hold a string offset",
});
view.setUint32(0, LC.CODE_SIGNATURE, true);
expect(await read(machoImage({ commands: [short] }))).toMatchObject({
@@ -1945,9 +1943,7 @@ describe("Mach-O load command reader failures", () => {
const fat = fatImage([{ cpu: CPU.arm64, bytes: machoImage({}) }]);
expect(await read(fat.subarray(0, 4100))).toMatchObject({
status: "malformed",
reason: expect.stringMatching(
/FAT architecture 0\b.*slice range.*beyond.*file/iu,
),
reason: "FAT architecture 0 slice range extends beyond the file",
});
});
@@ -1972,9 +1968,7 @@ describe("Mach-O load command reader defect precedence", () => {
new DataView(unsized.buffer).setUint32(0, LC.RPATH, true);
const decodeFirst = {
status: "malformed",
reason: expect.stringMatching(
/load command 0\b.*too short.*string offset/iu,
),
reason: "load command 0 is too short to hold a string offset",
};
// An earlier command's decode defect wins over a later framing defect,
expect(await read(machoImage({ commands: [shortRpath, unsized] }))).toEqual(
@@ -2025,9 +2019,7 @@ describe("Mach-O load command reader defect precedence", () => {
new DataView(ranged.buffer).setUint32(8 + 20 + 12, ranged.length, false);
expect(await read(ranged)).toEqual({
status: "unsupported",
reason: expect.stringMatching(
/FAT architecture 0\b.*alignment exponent 53\b.*unsupported/iu,
),
reason: "FAT architecture 0 alignment exponent 53 is unsupported",
});
});
});
+2 -6
View File
@@ -540,12 +540,8 @@ describe("binary plist numeric association", () => {
9007199254740992, 9007199254740992,
]);
expect(numbers.limitations()).toEqual([
expect.stringMatching(
/\b1 observed integer literal.*original archive.*JSON number.*lose integer precision.*\$plist_type.*integer.*decimal.*original archive bytes retain/iu,
),
expect.stringMatching(
/\b2 number.*integer range.*source integer\/real type.*cannot be associated unambiguously.*precision loss/iu,
),
'1 observed integer literal(s) in the original archive exceed the exact range of a JSON number. Decoded numbers may lose integer precision unless represented as { "$plist_type": "integer", "decimal": "<exact digits>" }; the original archive bytes retain those literals.',
"2 number(s) beyond the exact integer range remain as decoded because their source integer/real type or reference marker cannot be associated unambiguously; integer precision loss remains possible.",
]);
});
@@ -14,6 +14,7 @@ it("refuses an XML plist whose DOM exceeds the native decode budget", () => {
const parsed = parsePlistXmlSource(
xmlPlist(`<array>\n${"<true/>\n".repeat(100_000)}</array>\n`),
);
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("Expected a decode budget refusal");
expect(parsed.error).toBeInstanceOf(AnalysisResourceConstraintError);
expect(parsed.error.message).toContain("native plist decode budget");
@@ -27,6 +28,7 @@ it.each(["é", "_", ":", "雪", "\u{1d4b3}"])(
const parsed = parsePlistXmlSource(
xmlPlist(`<array>${`<${name}/>`.repeat(100_000)}</array>`),
);
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("Expected a decode budget refusal");
expect(parsed.error).toBeInstanceOf(AnalysisResourceConstraintError);
},
@@ -36,6 +38,7 @@ it("refuses text whose entity references exceed the decode budget", () => {
const parsed = parsePlistXmlSource(
xmlPlist(`<array><string>${"&amp;".repeat(1_000_000)}</string></array>`),
);
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("Expected a decode budget refusal");
expect(parsed.error).toBeInstanceOf(AnalysisResourceConstraintError);
});
+2 -2
View File
@@ -3,13 +3,13 @@ import { join } from "node:path";
import { expect, it, vi } from "vitest";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import {
locateBunStandaloneGraph,
probeBunStandaloneGraph,
bunModuleCountLimit,
BUN_GRAPH_SUFFIX_BYTES,
} from "./BunModuleGraphLayout.js";
import { decodeBunModuleGraph } from "./BunModuleGraphMembers.js";
import {
locateBunGraphFixture,
bunModuleGraphPayload,
bunPrefixedPayload,
machoBunSection,
@@ -25,7 +25,7 @@ async function locate(bytes: Buffer) {
await writeFile(path, bytes);
const handle = await open(path, "r");
try {
const result = await locateBunGraphFixture(handle, bytes.length);
const result = await locateBunStandaloneGraph(handle, bytes.length);
if (result.status === "present" && result.graph !== undefined) {
const graph = result.graph;
return {
+11
View File
@@ -124,6 +124,17 @@ interface SectionWindow {
readonly truncated: boolean;
}
/** Probe, then read the one claimed payload. */
export const locateBunStandaloneGraph = async (
handle: FileHandle,
fileSize: number,
signal?: AbortSignal,
): Promise<BunStandaloneRecognition> => {
const probed = await probeBunStandaloneGraph(handle, fileSize, signal);
if (probed.status === "absent" || probed.claim === undefined) return probed;
return loadBunStandaloneGraph(handle, probed.claim, signal);
};
/**
* Locate at most one graph claim with bounded reads of headers, length
* prefixes, and suffixes. Payload data is not read. Multiple real claims are
@@ -1,26 +1,12 @@
import type { FileHandle } from "node:fs/promises";
import {
BUN_OFFSETS_BYTES,
BUN_RECORD_BYTES_1_3_3,
BUN_RECORD_BYTES_1_3_9,
BUN_TRAILER,
loadBunStandaloneGraph,
probeBunStandaloneGraph,
type BunLayoutRevision,
type BunStandaloneRecognition,
type BunStringPointer,
} from "./BunModuleGraphLayout.js";
/** Exercise the same two-stage probe and payload read as the artifact reader. */
export const locateBunGraphFixture = async (
handle: FileHandle,
fileSize: number,
): Promise<BunStandaloneRecognition> => {
const probed = await probeBunStandaloneGraph(handle, fileSize);
if (probed.status === "absent" || probed.claim === undefined) return probed;
return loadBunStandaloneGraph(handle, probed.claim);
};
export interface BunFixtureModule {
readonly name?: string | Buffer;
readonly contents?: Buffer;
@@ -194,6 +194,7 @@ it.each(["inventory_artifact", "inspect_artifact"] as const)(
{},
{ signal: controller.signal },
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected provider cancellation");
expect(projectAnalysisError(result.error).code).toBe("cancelled");
expect(result.error.cause).toBe(reason);
@@ -83,7 +83,7 @@ it.each([
ok: false,
error: {
reason: "format",
message: expect.stringContaining("UTF-8"),
message: "PYZ marshal string is not valid UTF-8",
},
});
});
@@ -127,7 +127,7 @@ it.each(["6c0200000003000000", "6cfeffffff03000000"])(
ok: false,
error: {
reason: "format",
message: expect.stringMatching(/long.*not normalized/iu),
message: "PYZ marshal long is not normalized",
},
});
},
@@ -922,7 +922,7 @@ it("retains the descriptor owner when admission cleanup fails", async () => {
reader.entries()[Symbol.asyncIterator]().next(),
).rejects.toMatchObject({
reason: "format",
message: expect.stringContaining(directory),
message: `PyInstaller source is not a regular file: ${directory}`,
cleanup: {
reason: closeFailure.message,
resources: [directory],
@@ -311,7 +311,7 @@ it.each(["ENOENT", "ENOTDIR"] as const)(
);
expect(cause).toMatchObject({
reason: "integrity",
message: expect.stringContaining(path),
message: `PyInstaller source identity changed: ${path}`,
});
expect(
projectAnalysisError(
@@ -321,7 +321,7 @@ it.each(["ENOENT", "ENOTDIR"] as const)(
category: "integrity_mismatch",
details: {
reason: "integrity",
detail: expect.stringContaining(path),
detail: `PyInstaller source identity changed: ${path}`,
},
});
}
@@ -700,6 +700,7 @@ for (const cancel of [false, true]) {
release.resolve();
const result = await reading;
if (failClose || cancel) {
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected read failure");
expect(result.cause).toMatchObject(
failClose
@@ -708,6 +709,7 @@ for (const cancel of [false, true]) {
);
if (failClose) expect(owner?.handle.fd).toBeGreaterThanOrEqual(0);
} else {
expect(result.ok).toBe(true);
if (!result.ok) throw result.cause;
expect(
Buffer.compare(result.value.subarray(16), Buffer.from("body")),
@@ -20,7 +20,7 @@ it("classifies a source replacement during admission as an integrity failure", a
await expect(reader.prepareContainer("0".repeat(64))).rejects.toMatchObject(
{
reason: "integrity",
message: expect.stringContaining(path),
message: `PyInstaller source changed during admission: ${path}`,
},
);
expect(openRegularFile).toHaveBeenCalledWith(path, {
+2 -8
View File
@@ -176,10 +176,7 @@ it("reports a truncated table before deferred signature rejection", async () =>
header.writeUInt32BE(12, 8);
expect(await readImage(header, 16)).toMatchObject({
ok: false,
error: {
reason: "format",
message: expect.stringMatching(/table.*truncated/iu),
},
error: { reason: "format", message: "PYZ table is truncated" },
});
});
@@ -188,10 +185,7 @@ it("reports the signature after completing a readable table and image hash", asy
image.write("BAD\0");
expect(await readImage(image)).toMatchObject({
ok: false,
error: {
reason: "format",
message: expect.stringMatching(/magic.*match/iu),
},
error: { reason: "format", message: "PYZ archive magic does not match" },
});
});
@@ -26,6 +26,7 @@ it("shares a capture limit when browser providers use one admission", async () =
const pending = first.analyzeBundle(input);
const blocked = await second.analyzeBundle(input);
try {
expect(blocked.ok).toBe(false);
if (blocked.ok) throw new Error("expected the shared admission to refuse");
expect(projectAnalysisError(blocked.error)).toMatchObject({
code: "resource_constraint",
@@ -51,6 +52,7 @@ it("does not share a capture limit across browser providers", async () => {
const other = second.analyzeBundle(input);
try {
const blocked = await other;
expect(blocked.ok).toBe(false);
if (blocked.ok) throw new Error("expected capture to fail closed");
expect(projectAnalysisError(blocked.error).code).not.toBe(
"resource_constraint",
+1
View File
@@ -203,6 +203,7 @@ it.each(
target_id: "duplicate",
observation_ms: 0,
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected ambiguous target rejection");
expect(result.error).toBeInstanceOf(BrowserObservationError);
expect(result.error.userMessage).toContain("ambiguous");
@@ -140,6 +140,7 @@ it("retains Inspector scripts and contexts when final ASAR authorization cannot
};
try {
const control = await observeJavaScriptRuntime(provider, input);
expect(control.ok).toBe(true);
if (!control.ok) throw control.error;
expect(control.value.normalized_result).toMatchObject({
scripts: { observed_total: 1 },
@@ -148,6 +149,7 @@ it("retains Inspector scripts and contexts when final ASAR authorization cannot
await prependAsarJsonWhitespace(archive, " ");
handleState.failCloseCount = 1;
const result = await observeJavaScriptRuntime(provider, input);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected descriptor close failure");
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.partialObservation).toMatchObject({
@@ -63,6 +63,7 @@ it("preserves completed prefix and suffix facts when one script times out", asyn
expect(calls).toEqual(
captured.scripts.items.map(({ script_key }) => script_key),
);
expect(result.ok).toBe(true);
if (!result.ok) throw result.error;
expect(
result.value.observations.chunks.edges.map(({ specifier }) => specifier),
@@ -113,6 +114,7 @@ it("marks the unstarted suffix when the aggregate deadline expires", async () =>
expect(calls).toEqual(
captured.scripts.items.slice(0, 2).map(({ script_key }) => script_key),
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected total deadline failure");
expect(result.error._tag).toBe("AnalysisTimeoutError");
expect(result.error.partialObservation).toMatchObject({
@@ -174,6 +176,7 @@ it("stops after a middle-script result-capacity failure and retains prefix facts
expect(calls).toEqual(
captured.scripts.items.slice(0, 2).map(({ script_key }) => script_key),
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected result-capacity failure");
expect(result.error).toBe(primary);
expect(result.error.partialObservation).toMatchObject({
@@ -231,6 +234,7 @@ it("carries the exact caller cancellation reason and completed prefix facts", as
const result = await pending;
analyzer.releaseReservation();
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancellation");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(exactReason);
@@ -272,6 +276,7 @@ it("retains all completed facts when cancellation arrives during worker close",
const result = await pending;
analyzer.releaseReservation();
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancellation during finalization");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.partialObservation).toMatchObject({
@@ -311,6 +316,7 @@ it("retains cleanup ownership after mixed worker/close failure and resolves it o
expect((await analyzer.reserve()).ok).toBe(true);
const result = await analyzer.analyze(captured, noSourceMaps, input);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected mixed failure");
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.cleanupPrimary).toBe(primary);
@@ -341,6 +347,7 @@ it("shares one capture reservation when analyzers use one admission", async () =
const second = new IsolatedWebBundleAnalyzer(() => inertWorker(), admission);
expect((await first.reserve()).ok).toBe(true);
const blocked = await second.reserve();
expect(blocked.ok).toBe(false);
if (blocked.ok) throw new Error("Expected shared admission refusal");
expect(projectAnalysisError(blocked.error)).toMatchObject({
code: "resource_constraint",
@@ -318,6 +318,7 @@ it("retains the partial capture without taskkill when Windows lineage is unavail
application_root: dirname(process.execPath),
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected unverified cleanup to fail");
expect(terminateTree).not.toHaveBeenCalled();
expect(result.error).toMatchObject({
@@ -378,6 +379,7 @@ it("retains the initial snapshot when the final state read fails", async () => {
application_root: dirname(process.execPath),
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected final state capture to fail");
const partialObservation = result.error.partialObservation;
expect(partialObservation).toMatchObject({
@@ -410,6 +412,7 @@ it("rejects active capture before launch when EnableNodeCliInspectArguments fuse
application_root: root,
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected capture to fail");
expect(launchSpy).not.toHaveBeenCalled();
expect(result.error).toMatchObject({
@@ -506,6 +509,7 @@ it("cancels a windowless wait action and cleans up the owned application", async
expect(application.firstWindow).toHaveBeenCalledOnce();
expect(application.close).toHaveBeenCalledOnce();
expect(cleanupSpies.some((spy) => spy.mock.calls.length > 0)).toBe(true);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the capture to be cancelled");
expect(result.error).toMatchObject({
_tag: "BrowserObservationError",
@@ -617,6 +621,7 @@ it("cancels a pending initial state read and cleans up the owned application", a
expect(application.close).toHaveBeenCalledOnce();
expect(cleanupSpies.some((spy) => spy.mock.calls.length > 0)).toBe(true);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the capture to be cancelled");
expect(result.error).toMatchObject({
_tag: "BrowserObservationError",
@@ -661,6 +666,7 @@ it("observes an initial state read started after cancellation and rejected by cl
expect(application.close).toHaveBeenCalledOnce();
expect(cleanupSpies.some((spy) => spy.mock.calls.length > 0)).toBe(true);
expect(unhandled).toEqual([]);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the capture to be cancelled");
expect(result.error).toMatchObject({
_tag: "BrowserObservationError",
@@ -697,6 +703,7 @@ it("keeps a failure observed with cancellation as the cancellation cause", async
const result = await captureWithController(controller, application, []);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the capture to be cancelled");
expect(result.error).toMatchObject({
_tag: "BrowserObservationError",
@@ -735,6 +742,7 @@ it("reports an expired final state grace period as the caller's cancellation", a
await vi.advanceTimersByTimeAsync(5_000);
const result = await capture;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the capture to be cancelled");
expect(result.error).toMatchObject({
_tag: "BrowserObservationError",
@@ -787,6 +795,7 @@ it("cancels a completed capture when cancellation arrives during application clo
const result = await captureWithController(controller, application, []);
expect(application.close).toHaveBeenCalledOnce();
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the capture to be cancelled");
expect(result.error).toMatchObject({
_tag: "BrowserObservationError",
@@ -829,6 +838,7 @@ it("cancels a completed capture when cancellation arrives during process cleanup
const result = await captureWithController(controller, application, []);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the capture to be cancelled");
expect(result.error).toMatchObject({
_tag: "BrowserObservationError",
@@ -863,6 +873,7 @@ it("keeps cleanup failure precedence over cancellation observed during cleanup",
const result = await captureWithController(controller, application, []);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cleanup to fail");
expect(result.error).toMatchObject({
cleanupIncomplete: true,
@@ -914,6 +925,7 @@ it.each(["process", "transport"] as const)(
const result = await captureWithController(controller, application, []);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected incomplete cleanup");
expect(result.error).toMatchObject({
cleanupIncomplete: true,
@@ -1019,6 +1019,7 @@ it("preserves literal replacement characters in valid UTF-8 worker facts", async
makeScript("fetch('/literal-�');"),
10_000,
);
expect(result.ok).toBe(true);
if (!result.ok) throw new Error(result.error.error.message);
if (result.value.status !== "analyzed")
throw new Error("Expected analyzed worker facts");
@@ -48,6 +48,7 @@ it("refuses before spawning or reading source when no host result capacity remai
try {
const result = await worker.analyze(script, 10_000);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected result-capacity refusal");
const refusal = result.error.error;
expect(refusal).toBeInstanceOf(AnalysisResourceConstraintError);
@@ -144,6 +144,7 @@ it("retains completed facts and resolves a result-file lease through another cle
makeScript("fetch('/retained');"),
10_000,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected retained result cleanup failure");
expect(result.error.completed).toMatchObject({
status: "analyzed",
+1 -1
View File
@@ -8,7 +8,7 @@ import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { analysisErrorProjectionSchema } from "../../contracts/errorSchemas.js";
import { inspectWebNetworkCaptureInputSchema } from "../../domain/webNetworkCapture.js";
import { historicalCaptureFailure } from "./CaptureFailures.js";
import { NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES } from "../../../tests/fixtures/captureDecoderLimits.js";
import { NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES } from "./CaptureRelease.js";
import { redactExplicitFailure } from "../../domain/explicitSensitiveFailure.js";
import { OwnedCommandFailure } from "../../process/OwnedCommand.js";
+6
View File
@@ -24,4 +24,10 @@ export const captureDecoderProcessMemoryLimitBytes = (
export const NODE_CAPTURE_HEAP_ENFORCED =
javaScriptRuntime(process.versions).id === "node";
/** Commit limit for the default heap. A configured heap uses `captureDecoderProcessMemoryLimitBytes`. */
export const NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES =
captureDecoderProcessMemoryLimitBytes(
NODE_CAPTURE_HEAP_LIMITS.oldGenerationMiB,
);
/** Fixed offline interpretation profile, not an authenticated producer claim. */
@@ -217,6 +217,7 @@ it("refuses other declared trace versions with verified archive identity", async
const result = await new PlaywrightTraceDecoder(resources).inspect(
fixture.path,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected version refusal");
expect(result.error._tag).toBe("AnalysisUnsupportedTargetError");
expect(
@@ -238,6 +239,7 @@ it("preserves opaque pre-cancellation without reading or claiming a source snaps
"/unused.zip",
controller.signal,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancellation");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(reason);
@@ -305,6 +307,7 @@ it("refuses oversized physical lines before parsing their representation", async
const result = await new PlaywrightTraceDecoder(resources).inspect(
fixture.path,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected line capacity refusal");
expect(result.error._tag).toBe("AnalysisResourceConstraintError");
expect(resources.hasPending).toBe(false);
@@ -326,6 +329,7 @@ it("never promotes facts from a stream whose ZIP CRC fails", async () => {
const result = await new PlaywrightTraceDecoder(resources).inspect(
fixture.path,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected CRC refusal");
expect(result.error._tag).toBe("AnalysisInputError");
const partial = playwrightTraceSchema.parse(
@@ -350,6 +354,7 @@ it("rejects a sparse over-budget archive before hashing or expansion", async ()
const result = await new PlaywrightTraceDecoder(resources).inspect(
fixture.path,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected archive admission refusal");
expect(result.error._tag).toBe("AnalysisResourceConstraintError");
expect(result.error.partialObservation).toBeUndefined();
@@ -373,6 +378,7 @@ it("bounds JSON node expansion even when many tokens fit inside a small physical
const result = await new PlaywrightTraceDecoder(resources).inspect(
fixture.path,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected node admission refusal");
expect(result.error._tag).toBe("AnalysisResourceConstraintError");
expect(resources.hasPending).toBe(false);
@@ -26,6 +26,7 @@ it("preserves only fully verified streams when cancellation arrives before the n
const result = await new PlaywrightTraceService(
new PlaywrightTraceDecoder(resources),
).inspect({ path: fixture.path }, controller.signal);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancellation");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(reason);
@@ -60,6 +61,7 @@ it("retains complete Evidence and the exact failed reader owner when abort arriv
const result = await new PlaywrightTraceService(
new PlaywrightTraceDecoder(resources),
).inspect({ path: fixture.path }, controller.signal);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected composed failure");
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.cleanupPrimary?._tag).toBe("AnalysisCancelledError");
@@ -99,6 +101,7 @@ it("keeps completed Evidence on cancellation during the outer descriptor close",
const result = await new PlaywrightTraceService(
new PlaywrightTraceDecoder(resources),
).inspect({ path: fixture.path }, controller.signal);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancellation");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(reason);
@@ -133,6 +136,7 @@ it("keeps an existing unsupported-version failure ahead of cancellation during c
fixture.path,
controller.signal,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected version refusal");
expect(controller.signal.aborted).toBe(true);
expect(result.error._tag).toBe("AnalysisUnsupportedTargetError");
@@ -162,6 +166,7 @@ it("retains independent reader and file owners until both cleanup retries comple
const result = await new PlaywrightTraceService(
new PlaywrightTraceDecoder(resources),
).inspect({ path: fixture.path });
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cleanup failure");
expect(result.error.cleanupIncomplete).toBe(true);
expect(resources.cleanupResources).toHaveLength(2);
@@ -211,6 +216,7 @@ it.skipIf(process.platform === "win32")(
const result = await new PlaywrightTraceDecoder(resources).inspect(
fixture.path,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected source identity refusal");
expect(result.error._tag).toBe("AnalysisArtifactChangedError");
expect((await readFile(held)).equals(original)).toBe(true);
@@ -242,6 +248,7 @@ it("bounds active imports without an unbounded queue and releases admission afte
try {
await reached.promise;
const second = await port.inspect(fixture.path);
expect(second.ok).toBe(false);
if (second.ok) throw new Error("Expected capacity refusal");
expect(second.error._tag).toBe("AnalysisResourceConstraintError");
release.resolve();
+12
View File
@@ -227,6 +227,18 @@ export const delimitedOutputInputError = (
{ path, reason: "invalid_value", message },
]);
/** Render a validated row page, preceded by a versioned metadata record. */
export const formatDelimitedRows = (
rows: unknown,
format: "csv" | "tsv",
options: CliDelimitedOutputOptions,
): Result<string, AnalysisInputError> => {
const prepared = prepareDelimitedOutput(rows, format, options);
return prepared.ok
? ok([...formatDelimitedOutputParts(prepared.value)].join(""))
: prepared;
};
/** Stream a page after its rows and cells have been validated and snapshotted. */
export function* formatDelimitedOutputParts({
encodedHeaders,
+1 -1
View File
@@ -295,7 +295,7 @@ const registerXrefsCommand = (
runDirectAnalysis: DirectAnalysis["runDirectAnalysis"],
): void => {
cli.command(CLI_COMMANDS.xrefs, {
description: `List bounded, untyped references to an analyzed address. For typed incoming references, use inspect-native-instruction at the target address where supported. ${namedAddressGuidance} ${equalSelectorGuidance}`,
description: `List bounded references to an analyzed address. ${namedAddressGuidance} ${equalSelectorGuidance}`,
args: z.object({
path: localPathStringSchema.describe("App or program path"),
address: z
+66 -15
View File
@@ -51,26 +51,15 @@ export const registerCoreNativeCommands = (
): void => {
registerAnnotationCommand(cli, logger, runDirectAnalysis);
cli.command(CLI_COMMANDS.searchBytes, {
description:
"Find masked bytes; unbounded searches skip unloaded blocks and overlays",
description: "Find masked byte patterns in initialized provider memory",
args: z.object({
path: localPathStringSchema.describe("Local executable path"),
pattern: bytePatternFields.pattern,
}),
options: z.object({
mask: bytePatternFields.mask,
start: z
.string()
.optional()
.describe(
"Inclusive start address; use an overlay address space to search it",
),
end: z
.string()
.optional()
.describe(
"Inclusive end address; use an overlay address space to search it",
),
start: z.string().optional().describe("Inclusive start address"),
end: z.string().optional().describe("Inclusive end address"),
cursor: z
.string()
.optional()
@@ -151,6 +140,68 @@ export const registerCoreNativeCommands = (
}),
),
});
cli.command(CLI_COMMANDS.procedureReferences, {
description: `Inspect incoming or outgoing references for one native procedure. ${addressOrProcedureGuidance} ${equalSelectorGuidance}`,
args: z.object({
path: localPathStringSchema.describe("Local executable path"),
procedure: z
.string()
.min(1)
.optional()
.describe(
`Procedure name or provider address. ${addressOrProcedureGuidance}`,
),
}),
options: z.object({
procedure: z
.string()
.min(1)
.optional()
.describe(
`Procedure name or provider address; use --procedure=<value> for leading dashes. ${addressOrProcedureGuidance}`,
),
direction: z
.enum(["incoming", "outgoing"])
.default("outgoing")
.describe("Reference direction. Defaults to outgoing."),
snapshot: snapshotSelectionOption,
...analysisEvidenceDeliveryOptions,
...nativeOpenOptionFields,
}),
run: ({ args, options }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.procedureReferences, async () => {
const delivery = {
providerId: "auto",
operation: "procedure_references",
signal,
detail: options.detail,
evidenceOutput: options.evidence_output,
};
const invalid = cliEvidenceDeliveryInputError(delivery);
if (invalid !== undefined) return projectAnalysisError(invalid);
const result = await runDirectAnalysis(
args.path,
"procedure_references",
{
procedure: resolveCliAnalysisSelector(
args.procedure,
options.procedure,
"procedure",
"procedure_references",
),
direction: options.direction,
},
{
...directAnalysisOptionsFromCli(logger, options),
signal,
includeEvidenceSources: options.evidence_output !== undefined,
},
);
return publishCliAnalysisEvidence(result, delivery);
}),
),
});
cli.command(CLI_COMMANDS.readBytes, {
description:
"Read provider memory bytes; Ghidra memory may include loader fixups and differ from file bytes",
@@ -323,7 +374,7 @@ export const registerCoreNativeCommands = (
cli.command(command, {
description:
operation === "inspect_native_instruction"
? "Inspect an exact code or data address for decoded instructions and typed incoming references where supported"
? "Inspect one native instruction with decoded operand facts"
: "Resolve one static native call site",
args: z.object({
path: localPathStringSchema.describe(
+1
View File
@@ -35,6 +35,7 @@ export const CLI_COMMANDS = Object.freeze({
annotateNativeFunction: "annotate-native-function",
inspectNativeApi: "inspect-native-api",
instructions: "instructions",
procedureReferences: "procedure-references",
search: "search",
importReferenceSource: "import-reference-source",
inspectArtifact: "inspect-artifact",
+2
View File
@@ -38,6 +38,7 @@ it.each([
REA_GHIDRA_CHECKPOINT_DIR: "/tmp/checkpoints",
[setting]: value,
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("conflicting inputs accepted");
expect(result.error.message).toContain("checkpoints");
expect(result.error).toMatchObject({
@@ -69,6 +70,7 @@ it.each([
parseEnvironmentValues(environment),
parseConfig(environment),
]) {
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected invalid environment");
expect(result.error.settings).toHaveLength(1);
expect(result.error.settings[0]?.constraint).toContain("NUL");
+6 -14
View File
@@ -20,6 +20,7 @@ import { parseConfig } from "./parseConfig.js";
import {
CAPTURE_DECODER_OLD_GENERATION_SETTING,
captureDecoderHeapConstraint,
captureDecoderHeapRecovery,
DEFAULT_CAPTURE_DECODER_HEAP,
parseCaptureDecoderHeap,
} from "./captureDecoderHeap.js";
@@ -96,15 +97,9 @@ it("uses a valid REA_CAPTURE_DECODER_OLD_GENERATION_MIB override", async () => {
code: "resource_constraint",
details: { resource: "memory" },
});
expect(projected.remediation.action).toMatch(
/REA_CAPTURE_DECODER_OLD_GENERATION_MIB.*256 MiB.*source setting/iu,
expect(projected.remediation.action).toContain(
captureDecoderHeapRecovery(configured(256)),
);
expect(projected.remediation.action).toContain("--max-old-space-size=256");
expect(projected.remediation.action).toMatch(
/floor 8 MiB.*maximum 16384 MiB/iu,
);
expect(projected.remediation.action).toContain("update_analysis_settings");
expect(projected.remediation.action).toMatch(/restart REA/iu);
if (process.versions.bun === undefined)
expect(projected.details).toMatchObject({
limit: {
@@ -168,19 +163,16 @@ it("reports the heap the selected adapter launches when decoder environment diff
if (!reported.ok)
throw new Error(projectAnalysisError(reported.error).message);
const limitations = reported.value.limitations.join("\n");
expect(limitations).toMatch(
/REA_CAPTURE_DECODER_OLD_GENERATION_MIB.*256 MiB.*source setting/iu,
);
expect(limitations).toContain(captureDecoderHeapRecovery(launched.heap));
vi.spyOn(launched, "command").mockRejectedValue(heapExhaustion());
const exhausted = await decoder.inspect(input);
if (exhausted.ok) throw new Error("Heap exhaustion must fail inspection");
const projected = projectAnalysisError(exhausted.error);
expect(projected.code).toBe("resource_constraint");
expect(projected.remediation.action).toMatch(
/REA_CAPTURE_DECODER_OLD_GENERATION_MIB.*256 MiB.*source setting/iu,
expect(projected.remediation.action).toContain(
captureDecoderHeapRecovery(launched.heap),
);
expect(projected.remediation.action).toContain("--max-old-space-size=256");
if (process.versions.bun === undefined)
expect(projected.details).toMatchObject({
limit: {
+1
View File
@@ -34,6 +34,7 @@ it.each([
"9007199254740992",
])("refuses invalid allowance %s without disabling admission", (value) => {
const parsed = parseMcpInputBudget(value);
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("Invalid admission setting was accepted");
expect(parsed.error).toBeInstanceOf(ConfigurationError);
expect(parsed.error.settings).toEqual([
+17 -6
View File
@@ -5,7 +5,10 @@ import { KunaProvider } from "../kuna/KunaProvider.js";
import { silentLogger } from "../logger.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { regexMatchingDeadlineError } from "../process/RegexMatcher.js";
import {
regexMatchingDeadlineError,
launchRegexMatchingLimitation,
} from "../process/RegexMatcher.js";
import { parseEnvironment } from "./environment.js";
import { parseConfig } from "./parseConfig.js";
import {
@@ -82,7 +85,12 @@ it("uses a valid REA_REGEX_MATCHING_DEADLINE_MS override", () => {
.capabilities()
.find((capability) => capability.operation === "search_procedures")
?.limitations.join(" ");
expect(hopperText).toMatch(/10000 ms matching deadline.*source setting/iu);
expect(hopperText).toContain(
launchRegexMatchingLimitation(
parsed.value.regexMatchingDeadline,
parsed.value.regexWorkerHeapRequest,
),
);
vi.stubEnv(REGEX_MATCHING_DEADLINE_SETTING, "1");
try {
@@ -91,7 +99,12 @@ it("uses a valid REA_REGEX_MATCHING_DEADLINE_MS override", () => {
.capabilities()
.find((capability) => capability.operation === "search_strings")
?.limitations.join(" ");
expect(kunaText).toMatch(/10000 ms matching deadline.*source setting/iu);
expect(kunaText).toContain(
launchRegexMatchingLimitation(
parsed.value.regexMatchingDeadline,
parsed.value.regexWorkerHeapRequest,
),
);
} finally {
vi.unstubAllEnvs();
}
@@ -102,9 +115,7 @@ it("uses a valid REA_REGEX_MATCHING_DEADLINE_MS override", () => {
.capabilities()
.find((capability) => capability.operation === "search_procedures")
?.limitations,
).toContainEqual(
expect.stringMatching(/5000 ms matching deadline.*source default/iu),
);
).toContain(launchRegexMatchingLimitation());
});
it.each([
+17 -6
View File
@@ -5,7 +5,10 @@ import { KunaProvider } from "../kuna/KunaProvider.js";
import { silentLogger } from "../logger.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { regexWorkerHeapError } from "../process/RegexMatcher.js";
import {
regexWorkerHeapError,
launchRegexMatchingLimitation,
} from "../process/RegexMatcher.js";
import { parseEnvironment } from "./environment.js";
import { parseConfig } from "./parseConfig.js";
import {
@@ -96,7 +99,12 @@ it("uses a valid REA_REGEX_WORKER_HEAP_REQUEST_MIB override", () => {
.capabilities()
.find((capability) => capability.operation === "search_procedures")
?.limitations.join(" ");
expect(hopperText).toMatch(/256 MiB worker heap request.*source setting/iu);
expect(hopperText).toContain(
launchRegexMatchingLimitation(
parsed.value.regexMatchingDeadline,
parsed.value.regexWorkerHeapRequest,
),
);
vi.stubEnv(REGEX_WORKER_HEAP_REQUEST_SETTING, "8");
try {
@@ -107,7 +115,12 @@ it("uses a valid REA_REGEX_WORKER_HEAP_REQUEST_MIB override", () => {
.capabilities()
.find((capability) => capability.operation === "search_strings")
?.limitations.join(" ");
expect(kunaText).toMatch(/256 MiB worker heap request.*source setting/iu);
expect(kunaText).toContain(
launchRegexMatchingLimitation(
parsed.value.regexMatchingDeadline,
parsed.value.regexWorkerHeapRequest,
),
);
} finally {
vi.unstubAllEnvs();
}
@@ -118,9 +131,7 @@ it("uses a valid REA_REGEX_WORKER_HEAP_REQUEST_MIB override", () => {
.capabilities()
.find((capability) => capability.operation === "search_procedures")
?.limitations,
).toContainEqual(
expect.stringMatching(/128 MiB worker heap request.*source default/iu),
);
).toContain(launchRegexMatchingLimitation());
});
it.each([
+14 -8
View File
@@ -4,10 +4,12 @@ import { ConfigurationError } from "../domain/configurationErrors.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { parseEnvironment } from "./environment.js";
import {
DEFAULT_WINDOWS_CHANGE_TIME_SETTLE_LIMITS,
WINDOWS_CHANGE_TIME_DRIFT_MS,
WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS,
WINDOWS_CHANGE_TIME_SETTLE_POLL_MS,
resolveWindowsChangeTimeSettle,
windowsChangeTimeSettleRecovery,
windowsChangeTimeSettingFailure,
windowsChangeTimeSettingMessage,
} from "./windowsChangeTimeSettle.js";
@@ -35,14 +37,7 @@ const requiredFacts = (
it("keeps today's settle defaults when the settings are unset", () => {
expect(resolveWindowsChangeTimeSettle({})).toEqual({
ok: true,
value: {
driftMs: 2_000,
driftSource: "default",
pollMs: 25,
pollSource: "default",
maxPolls: 40,
maxPollsSource: "default",
},
value: DEFAULT_WINDOWS_CHANGE_TIME_SETTLE_LIMITS,
});
const startup = parseEnvironment({});
if (!startup.ok) throw new Error("unset settle settings were rejected");
@@ -51,6 +46,17 @@ it("keeps today's settle defaults when the settings are unset", () => {
expect(
startup.value.REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS,
).toBeUndefined();
requiredFacts(
windowsChangeTimeSettleRecovery(DEFAULT_WINDOWS_CHANGE_TIME_SETTLE_LIMITS),
{
setting: WINDOWS_CHANGE_TIME_DRIFT_MS.setting,
currentValue: "2000",
source: "default",
defaultValue: 2_000,
floor: 1,
maximum: 60_000,
},
);
});
it("uses valid settle overrides", () => {
+24
View File
@@ -68,6 +68,16 @@ export interface WindowsChangeTimeSettleLimits {
readonly maxPollsSource: "default" | "setting";
}
export const DEFAULT_WINDOWS_CHANGE_TIME_SETTLE_LIMITS: WindowsChangeTimeSettleLimits =
{
driftMs: WINDOWS_CHANGE_TIME_DRIFT_MS.defaultValue,
driftSource: "default",
pollMs: WINDOWS_CHANGE_TIME_SETTLE_POLL_MS.defaultValue,
pollSource: "default",
maxPolls: WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS.defaultValue,
maxPollsSource: "default",
};
export const windowsChangeTimeSettingFailure = (
bound: WindowsChangeTimeBound,
currentValue: string,
@@ -173,3 +183,17 @@ export const resolveWindowsChangeTimeSettle = (
maxPollsSource: maxPolls === undefined ? "default" : "setting",
});
};
/**
* Describe the effective budget. Admission still returns the last observation
* when the poll budget runs out; these settings only change how long it waits.
*/
export const windowsChangeTimeSettleRecovery = (
limits: WindowsChangeTimeSettleLimits,
): string =>
[
`The effective ${WINDOWS_CHANGE_TIME_DRIFT_MS.setting} value is ${String(limits.driftMs)} ms (source ${limits.driftSource}; default ${String(WINDOWS_CHANGE_TIME_DRIFT_MS.defaultValue)} ms, floor ${String(WINDOWS_CHANGE_TIME_DRIFT_MS.floor)} ms, maximum ${String(WINDOWS_CHANGE_TIME_DRIFT_MS.maximum)} ms).`,
`The effective ${WINDOWS_CHANGE_TIME_SETTLE_POLL_MS.setting} value is ${String(limits.pollMs)} ms (source ${limits.pollSource}; default ${String(WINDOWS_CHANGE_TIME_SETTLE_POLL_MS.defaultValue)} ms, floor ${String(WINDOWS_CHANGE_TIME_SETTLE_POLL_MS.floor)} ms, maximum ${String(WINDOWS_CHANGE_TIME_SETTLE_POLL_MS.maximum)} ms).`,
`The effective ${WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS.setting} value is ${String(limits.maxPolls)} polls (source ${limits.maxPollsSource}; default ${String(WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS.defaultValue)} polls, floor ${String(WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS.floor)} polls, maximum ${String(WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS.maximum)} polls).`,
"Set them in the environment that starts REA, then restart REA. They do not change Windows ChangeTime itself.",
].join(" ");
+1 -1
View File
@@ -17,7 +17,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [
...toolContractMetadata("inspect_analysis_view"),
kind: "application",
description:
'For inspect_api_contract parents, graphql-contract pages retained SDL types/fields, operation definitions, field references, fragment-spreads or diagnostics with exact filters and selected columns. Use javascript-routes collections to query route patterns, source-linked occurrences, source status, unknowns or summary counts from retained JavaScript application or web-bundle Evidence. Use web-bundle collections endpoints or inferences to page endpoint observations and inferences from retained analyze_web_bundle Evidence; summary returns target URL, origin, and counts. For retained trace_feature Evidence, kind native-feature-trace pages matches, references or unknowns; matches filter exact address, references filter exact source_address and target_address, source_index identifies each original row, and list_unknowns pages durable question heads. Inspect selected views of supported completed Evidence without repeating analysis. For procedure_pseudo_code Evidence, summary reports text availability and the original selection; kind native with facet pseudocode pages the original UTF-16 text. Other native facets require analyze_function. Choose a view compatible with the parent operation described below. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Native export tables use kind native-exports, collection exports/issues, exact names or original indices, selected fields and matching-row pagination; parent Evidence plus source_index identifies each row. Source coverage stays separate from page coverage. BEAM views use kind beam, collections atoms/exports/imports/lambdas/chunks, exact names/indices/fields and matching-row pages; import modules intersects function names. source_index identifies original parent-and-collection rows, preserving every arity and raw atom index. beam.counts preserves null missing tables versus decoded empty tables; runtime absence remains unknown. PYC views use kind python-pyc, collection code_objects, names/indices/fields and paging; source_index scopes every same-name alternative to the parent, nested_code keeps code-constant references, and pyc.coverage preserves source coverage. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent also supports summary and bLine truncated
'Use native-procedure-references to page retained procedure reference or unresolved-call rows. Endpoint kind selected-procedure refers to the summary procedure, null preserves the original null, and shared carries an index into items.endpoints, where each other procedure is stored once per page. Exact source_address and target_address filters apply to reference rows. For inspect_api_contract parents, graphql-contract pages retained SDL types/fields, operation definitions, field references, fragment-spreads or diagnostics with exact filters and selected columns. Use javascript-routes collections to query route patterns, source-linked occurrences, source status, unknowns or summary counts from retained JavaScript application or web-bundle Evidence. Use web-bundle collections endpoints or inferences to page endpoint observations and inferences from retained analyze_web_bundle Evidence; summary returns target URL, origin, and counts. For retained trace_feature Evidence, kind native-feature-trace pages matches, references or unknowns; matches filter exact address, references filter exact source_address and target_address, source_index identifies each original row, and list_unknowns pages durable question heads. Inspect selected views of supported completed Evidence without repeating analysis. For procedure_pseudo_code Evidence, summary reports text availability and the original selection; kind native with facet pseudocode pages the original UTF-16 text. Other native facets require analyze_function. Choose a view compatible with the parent operation described below. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Native export tables use kind native-exports, collection exports/issues, exact names or original indices, selected fields and matching-row pagination; parent Evidence plus source_index identifies each row. Source coverage stays separate from page coverage. BEAM views use kind beam, collections atoms/exports/imports/lambdas/chunks, exact names/indices/fields and matching-row pages; import modules intersects function names. source_index identifies original parent-and-collection rows, preserving every arity and raw atom index. beam.counts preserves null missing tables versus decoded empty tables; runtime absence remains unknown. PYC views use kind python-pyc, collection code_objects, names/indices/fields and paging; source_index scopes every same-name alternative to the parent, nested_code keeps code-constant references, and pyc.coverage preserves source coverage. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, statuLine truncated
inputSchema: inspectAnalysisViewInputSchema,
outputSchema: evidenceResultOf(analysisViewResultSchema),
examples: [
+1 -1
View File
@@ -11,7 +11,7 @@ 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. Without bounds, unloaded blocks and loaded overlays are skipped and listed separately; select an overlay address space with start or end to search it. Matches are ordered by provider address and may span adjacent eligible initialized blocks; gaps and excluded blocks end a contiguous range. A match’s segment and file offset describe its starting address. Coverage describes this page; reuse the same query with next_cursor to continue. Uninitialized blocks are listed separately. Ghidra only; Hopper is unavailable.",
"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 may span adjacent eligible initialized blocks; gaps and excluded blocks end a contiguous range. A match’s segment and file offset describe its starting address. 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,
+5 -3
View File
@@ -205,12 +205,14 @@ export const OFFICIAL_TOOL_CONTRACTS = [
),
official(
"procedure_references",
"Return every raw incoming or outgoing reference edge for one procedure. Endpoint procedures are resolved only from provider containment. Ghidra preserves detailed reference flags. Hopper preserves native CallReference classifications and exact endpoints while leaving unavailable detailed flags explicit.",
"Return incoming or outgoing reference edges for one procedure. Ghidra preserves detailed reference flags; Hopper preserves native CallReference classifications and exact endpoints. Select detail summary to retain complete Evidence and page references or unresolved calls using inspect_analysis_view kind native-procedure-references. Page coverage describes retained observations, not complete discovery or proof the target ran.",
z.object({
procedure,
direction: z.enum(["incoming", "outgoing"]).default("outgoing"),
document,
detail: analysisDetailSchema,
}),
{ evidenceParameters: "omit-detail" },
),
official(
"procedure_pseudo_code",
@@ -231,13 +233,13 @@ export const OFFICIAL_TOOL_CONTRACTS = [
),
official(
"search_procedures",
`Search every analyzed procedure name, including secondary names exact lookup accepts, using literal matching by default or regex when requested. Each procedure appears once under its primary name; matched_aliases lists matching secondary names. Results are deterministic and complete. Ghidra rows retain their reported external and thunk classification. For Ghidra case-insensitive literal searches, raw_result contains unfiltered candidates; normalized_result contains matches. ${hopperRegexDeadline} Select detail summary for counts and retained native-inventory pages; complete remains the default.`,
`Search every analyzed procedure name, including secondary names exact lookup accepts, using literal matching by default or regex when requested. Each procedure appears once under its primary name; matched_aliases lists matching secondary names. Results are deterministic and complete. For Ghidra case-insensitive literal searches, raw_result contains unfiltered candidates; normalized_result contains matches. ${hopperRegexDeadline} Select detail summary for counts and retained native-inventory pages; complete remains the default.`,
z.object({ ...nativeSearchInput, detail: analysisDetailSchema }),
{ evidenceParameters: "omit-detail" },
),
official(
"search_strings",
`Search every provider-reported string object or candidate using literal matching by default or regex when requested. Results are deterministic and complete over that provider inventory, not every text sequence in artifact bytes. Kuna scans mapped sequences of at least five characters, including unterminated fragments, without computing references. Ghidra reports each defined string's encoding, byte length and termination; it may omit packed or undefined literals, including Rust strings; an empty result does not prove text is absent. For known encoded bytes, Ghidra search_bytes can find byte matches; a hit does not establish a string or use. If an address is known, xrefs can look for references; read_bytes can inspect bytes where supported (not available with Kuna). Hopper retains typed byte decoding, native display, and explicit decoding unknowns. Adjacent long-literal fragments match independently. For Ghidra case-insensitive literal searches, raw_result contains unfiltered candidates; normalized_result contains matches. ${hopperRegexDeadline} Select detail summary for counts and retained native-inventory pages; complete remains the default.`,
`Search every provider-reported string object or candidate using literal matching by default or regex when requested. Results are deterministic and complete over that provider inventory, not every text sequence in artifact bytes. Kuna scans mapped sequences of at least five characters, including unterminated fragments, without computing references. Ghidra may omit packed or undefined literals, including Rust strings; an empty result does not prove text is absent. For known encoded bytes, Ghidra search_bytes can find byte matches; a hit does not establish a string or use. If an address is known, xrefs can look for references; read_bytes can inspect bytes where supported (not available with Kuna). Hopper retains typed byte decoding, native display, and explicit decoding unknowns. Adjacent long-literal fragments match independently. For Ghidra case-insensitive literal searches, raw_result contains unfiltered candidates; normalized_result contains matches. ${hopperRegexDeadline} Select detail summary for counts and retained native-inventory pages; complete remains the default.`,
z.object({ ...nativeSearchInput, detail: analysisDetailSchema }),
{ evidenceParameters: "omit-detail" },
),
+5 -13
View File
@@ -1,3 +1,4 @@
import { procedureReferencesSchema } from "../domain/native/procedureReferences.js";
import {
nativeAddressListSchema,
nativeObservationSourceSchemas,
@@ -11,6 +12,7 @@ import {
nativeFunctionSummaryViewResultSchema,
nativeInventorySummaryViewResultSchema,
nativeFeatureTraceSummaryViewResultSchema,
nativeProcedureReferencesSummaryViewResultSchema,
batchDecompileSummaryViewResultSchema,
nativeLoadImageSummaryViewResultSchema,
artifactInventorySummaryViewResultSchema,
@@ -33,11 +35,6 @@ import {
analysisBookmarkSchema,
nativeInventoryRowsSchemas,
} from "../domain/native/nativeInventory.js";
import {
referenceEdgeSchema,
unresolvedCallSchema,
} from "../domain/native/nativeReference.js";
import { procedureIdentitySchema } from "../domain/native/procedureInfo.js";
import { z } from "zod";
import {
evidenceBundleExportReceiptSchema,
@@ -234,14 +231,9 @@ export const officialOutputSchemas = {
),
}),
),
procedure_references: evidenceResultOf(
z.object({
procedure: procedureIdentitySchema,
direction: z.enum(["incoming", "outgoing"]),
reference_kinds_available: z.boolean(),
unresolved_calls: z.array(unresolvedCallSchema),
references: z.array(referenceEdgeSchema),
}),
procedure_references: analysisDetailOutputSchemaOf(
procedureReferencesSchema,
nativeProcedureReferencesSummaryViewResultSchema,
),
procedure_pseudo_code: analysisDetailOutputSchemaOf(
nullableText,
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { managedNativeVerificationInputSchema } from "../domain/managed/managedNativeVerificationSchemas.js";
import { toolContract } from "./toolContracts.js";
import { toolInputSchemaWithMetadata } from "./toolSchemaMetadata.js";
@@ -132,6 +133,7 @@ describe("workflow input contracts", () => {
};
const contract = toolContract("verify_managed_native_boundaries");
for (const schema of [
managedNativeVerificationInputSchema,
contract.inputSchema,
toolInputSchemaWithMetadata(contract),
]) {
+10 -11
View File
@@ -355,11 +355,11 @@ it("projects a published file receipt alongside retained cleanup failure", () =>
);
expect(projected).toMatchObject({
code: "cleanup_incomplete",
message: expect.stringMatching(/published.*staging cleanup.*incomplete/iu),
message:
"The output file was published, but staging cleanup remains incomplete.",
remediation: {
action: expect.stringMatching(
/published.*details\.published_path.*cleanup/iu,
),
action:
"Use the published file at details.published_path and resolve the reported staging cleanup failure.",
},
details: {
operation: "write",
@@ -1070,9 +1070,8 @@ describe("analysis error projection: caller contract", () => {
category: "unavailable",
retryable: false,
remediation: {
action: expect.stringMatching(
/read access.*selected path.*readable local file/iu,
),
action:
"Check the current process's read access to the selected path. Retry with a readable local file.",
},
details: {
path: "/local/targets/app",
@@ -1142,11 +1141,11 @@ describe("analysis error projection: caller contract", () => {
projectAnalysisError(new UnknownRegistryError("not-found")),
).toMatchObject({
code: "execution_failure",
message: expect.stringMatching(
/residual unknown.*does not exist.*unknown_id/iu,
),
message:
"The requested residual unknown does not exist in this session. Check the unknown_id and try again.",
remediation: {
action: expect.stringMatching(/unknown_id.*session.*retry/iu),
action:
"Check that the unknown_id belongs to this session, then retry.",
},
details: { reason: "not-found" },
});
+3 -2
View File
@@ -23,7 +23,7 @@ import {
} from "../javascript/javascriptApplicationGraph.js";
import {
createJavaScriptSemanticGraph,
sealTransferredJavaScriptSemanticGraphSteps,
createImmutableJavaScriptSemanticGraphSteps,
} from "../javascript/javascriptSemanticGraph.js";
import { sourceMapReference } from "../javascript/javascriptApplicationGraph.fixture.js";
import { createArtifactInspection } from "../artifactInspection.js";
@@ -1093,6 +1093,7 @@ it("rejects ambiguous JavaScript module paths with candidate node ids", () => {
collection: "modules",
selector: { path: "renderer.js" },
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected ambiguous path");
expect(result.error).toMatchObject({
_tag: "AnalysisInputError",
@@ -1281,7 +1282,7 @@ it("reuses authenticated graphs while rejecting malformed metadata and imported
);
const { graph_id: _semanticId, ...semantic } = analysis.semantic_graph;
const semanticGraph = complete(
sealTransferredJavaScriptSemanticGraphSteps({
createImmutableJavaScriptSemanticGraphSteps({
...semantic,
application_graph_id: graph.graph_id,
}),
Loaded 100 of 310 files, more files were not shown because too many files have changed in this diff. Show more