feat(native): page retained observations and source context (#7146)

* feat(native): page retained observations and source context

* docs(native): record complete observation workflow measurements

* fix(native): preserve context coverage and overflow recovery

* fix(benchmarks): finalize partially initialized observation probes

* docs(native): publish reviewed workflow verification
This commit is contained in:
gensokyo-dev authored and GitHub committed 2026-10-26 00:32:01 +08:00
1 parent 447907f280
commit 4c8647a706
29 files changed
+2266 -78

No files matched your search

@@ -403,6 +403,21 @@ encoded in bounded batches.
The 25000 ms Cutter request deadline and the 256-candidate discovery budget
still have no runtime override.
## Selected native facts and call graphs
For a large `get_call_graph`, or repeated `procedure_address`,
`procedure_callees`, `procedure_callers` and `resolve_containing_procedure`
queries, select `detail: "summary"` when the first 64 recorded observations
suffice. Read further `native-observation` pages from the returned parent Evidence
ID. Graph message IDs index that page's exact-text dictionary; keep level,
source index, failed nodes, and partial/unknown relationship coverage.
Read referenced source context through `native-context` facets `overview`,
`analysis-profile`, `limitations` and `result-limitations`. Reuse inspected
context/profile values only for the same digest; until caveats are read,
interpretation beyond the returned facts remains unknown. Complete Evidence
remains retained and exportable. Small complete replies can cost fewer tokens
than additional context/page calls; count the completed task and discovery.
## Static memory and original file bytes
For Ghidra, `read_bytes` reads initialized analysis memory, including loader
+30
View File
@@ -242,6 +242,36 @@ to `/absolute/view-request.json`, then run
`rea inspect-analysis-view /absolute/view-request.json --json`. This also reads
pre-summary source records without changing their identity; no live provider is started.
`procedure_address`, `procedure_callees`, `procedure_callers`,
`resolve_containing_procedure` and `get_call_graph` also accept `detail: "summary"`
(complete remains the default). The summary includes the first 64 recorded
observations, exact source parameters and provider/profile identity, relationship
coverage where recorded, and the retained complete parent's ID. Continue with
`view: {"kind":"native-observation","offset":64,"limit":64}`. Array positions
retain `source_index`; graph rows additionally retain their original `level`.
Graph pages order levels by Unicode code point and keep original row order within
each level. A recorded empty level is a `status: "empty-level"` row with
`source_index: null`; observation counts include these markers. Graph diagnostic,
limitation and reason IDs index that page's `items.messages` dictionary. Expand
those references to recover exact text, repeated messages, and absent versus
empty optional fields. Failed nodes retain their original error.
These views reference complete source context through `source_context.digest`
and caveat counts. Read it using the same parent with
`view: {"kind":"native-context","facet":"overview"}`. The other facets are
`analysis-profile`, `limitations` and `result-limitations`; all accept `offset`
and `limit`. Overview includes source subject, provider, authority, execution
environment and profile digest. Full profiles and caveat text remain accessible
without repeating them in every fact response. Context digests follow Evidence's
semantic subject identity, so caller-selected path aliases preserve context
identity while responses retain the selected display path. A null profile means
unavailable; null result-limitations means not applicable to a primitive and
is distinct from an observed empty graph limitation list. Until the source
caveats have been inspected, interpretations beyond the returned facts remain
unknown. Exhausted pages describe the recorded observations and do not establish
complete native discovery. The same views work through the CLI with historical
Evidence files or bundles and require no live provider.
For oversized native function Evidence, `details.reported_limits.continuation`
contains a ready-to-call `inspect_analysis_view` request for the retained
pseudocode's first page. Use its `name` and `arguments` directly, then follow
@@ -0,0 +1,113 @@
# Retained native observations and context
This experiment answers one fixed question over the unmodified stripped Linux
`/usr/bin/ls`: resolve 21 recorded functions, inspect every recorded caller/callee
relation and containing-function outcome, two complete graphs, and one missing
symbol. Repeat in the same session. Both lanes read all facts, source profiles,
and source caveats; no completeness advantage is inferred from smaller replies.
The final complete control uses `5d428390996498bc4dcca4da934641557f9109bc`,
after the unrelated input-schema fitting revert. The first control used
`2b0aa212156fe125c60aea8fd73092329e25dd09` (merged as `6381aa0b1`).
This already includes request-scoped Ghidra input-error diagnostics, so those
savings are not attributed to observation views. The candidate uses the
implementation in this PR. Both discover the entire actual compact SDK catalog,
use the same full 652-row inventory to choose seeds, and use named SDK tool
definitions for response validation. The summary client keeps exact-digest
context/profile values within this one investigation. The server adds no cache.
The first 64 observations arrive inline. Because this question explicitly needs
every remaining observation and every caveat, the client requests subsequent
pages of up to 512 entries. It decodes each graph page's message dictionary,
preserves original source indices, levels, duplicate text, optional fields,
failed nodes and coverage, and checks every reconstructed result against the
authenticated exported complete parent. All 65 original Evidence IDs and all
facts match the control in cold and warm phases. This establishes preservation
of the real Ghidra output, not independent completeness of Ghidra discovery.
Separate source fixtures establish graph ordering, duplicate/Unicode caveats,
empty levels, partial/unknown coverage, negative resolution, malformed omitted
rows, and authenticated path-alias identity.
## Environment and reproduction
- Target SHA-256: `cb30d69b24245bf2ecdc9e7f53bbad19159999970b6d82c0c00c7d32d9e37aa4`.
- Linux x86-64; Node 24.22.1; Ghidra 12.1.4; JDK 21.0.12.1.
- Fresh MCP process and Ghidra session for each lane; warm OS file caches.
The warm phase reuses the same provider session and the client's validated
context values. This is not a cold-disk comparison.
- Two CPU affinity slots; Node old-space limit 3 GiB and Ghidra heap 1536 MiB.
An external supervisor sampled the whole process family's summed RSS every
100 ms, stopped at 8 GiB or below 4 GiB available host memory, and enforced a
300-second deadline. No resource stop occurred. RSS can double-count shared
pages and miss short peaks. CPU includes driver, SDK, REA and Java descendants.
Build both worktrees with the pinned toolchain. Run this PR's driver against
each built runtime, with separate new output directories:
```sh
node experiments/analysis-efficiency/native-observation-views.mjs \
--runtime /absolute/control --target /usr/bin/ls --output /tmp/native-control \
--ghidra /absolute/ghidra --java /absolute/jdk --mode complete
node experiments/analysis-efficiency/native-observation-views.mjs \
--runtime /absolute/candidate --target /usr/bin/ls --output /tmp/native-summary \
--ghidra /absolute/ghidra --java /absolute/jdk --mode summary \
--control /tmp/native-control
```
Apply a process-family resource supervisor appropriate to the host. The driver
itself does not impose a family-memory limit. Each output keeps the actual
catalog, every RPC argument/reply, complete Evidence export, per-phase facts,
server log and completion/cleanup receipts. Failed runs retain their receipt.
## Measurements
| Completed task | Complete control | Summary + complete context |
| ------------------------------------------------ | ---------------: | -------------------------: |
| RPCs | 137 | 179 |
| Catalog token estimate | 919,115 | 932,545 |
| Catalog + arguments + text token estimate | 1,243,161 | 1,234,768 |
| Catalog + arguments + raw replies token estimate | 1,587,863 | 1,554,038 |
| All reply text bytes | 1,331,358 | 1,006,503 |
| Whole-family elapsed seconds | 39.38 | 41.06 |
| Whole-family CPU seconds | 69.26 | 70.4 |
| Sampled family peak RSS GiB | 1.45 | 1.69 |
The completed-task text estimate decreases 0.68%; raw-reply accounting decreases
2.13%, and text bytes decrease 24.4%. The large forward graph's first reply is
63,888 → 5,150 estimated text tokens (283,990 → 15,995 bytes); remaining facts
and all caveats are included in the completed-task table. A one-address callee
reply is only 754 → 744 tokens despite 3,104 → 2,326 bytes: bytes alone would
overstate its token improvement. There is no demonstrated time or memory
improvement. The complete-read workflow adds 42 RPCs; its warm phase is
0.762 → 1.005 seconds. Cold phases are 33.151 → 34.804 seconds. This one fresh
session per final lane cannot separate provider-startup variance from overhead.
The final candidate implementation and experiment driver are `4ac1971864`;
the final control is `5d428390996`. CPU and RSS remain within the stated guard
but are higher. An earlier pre-review pair measured 34.55 → 50.29 seconds,
so elapsed-time variance must not be mistaken for a speed improvement.
Token estimates use `cl100k_base` over one pass of raw catalog JSON plus every
RPC's arguments and text content. A second accounting counts the full raw RPC
reply, including duplicate structured/text representations. Both include page,
context, error, export and close calls. They exclude prompts, MCP initialization,
JSON-RPC envelopes, host rendering and replay, and are not actual model usage.
Catalog discovery dominates this example. No claim of better model reasoning
or complete discovery follows from these counts.
A first control attempt completed both investigations but its experiment driver
incorrectly parsed the export acknowledgement as Evidence; it failed during
verification, cleaned up, and was rerun after correcting the driver. A first
candidate with 64-entry continuation pages and duplicated coverage/verbose
caveat instructions increased the completed-task text token estimate by 1.2%.
That result was retained; the final implementation removes repeated coverage
from the normalized payload (the Evidence envelope owns it), shortens the
instructions, and the complete-read client requests larger continuation pages.
Neither failed/development attempt is included in the final paired task table.
Review regressions also cover context-facet partial/unknown relationship
coverage and actionable overflow continuations through all five native tools
without another provider read. The latter injects a 32 KiB delivery budget to
exercise capacity cheaply; it does not establish a real 10 MiB graph failure.
Actual missing-target and failing-Git commands each publish a failed receipt
without leaking a temporary HOME. Thirteen deterministic receipt tests also
cover partially acquired resources and failed cleanup.
@@ -0,0 +1,417 @@
/** Equivalent real Ghidra investigations, including every page and complete source context. */
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { execFile } from "node:child_process";
import { createWriteStream } from "node:fs";
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { finished } from "node:stream/promises";
import { parseArgs, promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { createRetainedQueryCaller } from "./retained-query-calls.mjs";
import { finalizeRetainedQueryReceipt } from "./retained-query-finalization.mjs";
const { values } = parseArgs({
options: Object.fromEntries(
["runtime", "target", "output", "ghidra", "java", "mode", "control"].map(
(name) => [name, { type: "string" }],
),
),
});
for (const key of ["runtime", "target", "output", "ghidra", "java"]) {
assert.ok(values[key], `Missing --${key}`);
values[key] = resolve(values[key]);
}
assert.ok(["complete", "summary"].includes(values.mode));
await mkdir(values.output);
const load = (path) => import(pathToFileURL(join(values.runtime, path)).href);
let parseEvidence, evidenceSubjectIdentity, digestCanonicalValue;
const hash = (value) => createHash("sha256").update(value).digest("hex");
let account, transport, serverLog, serverLogFinished, client, call;
const git = async (args) =>
(await promisify(execFile)("git", ["-C", values.runtime, ...args])).stdout;
const report = {
mode: values.mode,
runtime: values.runtime,
revision: null,
dirty: null,
target: values.target,
sha256: null,
question:
"Resolve 21 recorded functions, retain all caller/callee facts and two graphs, materialize every source profile and caveat, and inspect one unknown-symbol error, twice in one session.",
calls: [],
runs: [],
};
const transcript = [];
const good = async (name, input, phase) => {
const reply = await call(name, input, phase);
assert.notEqual(reply.isError, true, JSON.stringify(reply));
return parseEvidence(reply.structuredContent);
};
const page = async (id, view, phase) =>
(
await good(
"inspect_analysis_view",
{ source: { kind: "retained-evidence", evidence_id: id }, view },
phase,
)
).normalized_result;
const contexts = new Map(),
profiles = new Map(),
expectedParents = new Map();
const readContext = async (summary, phase) => {
const ref = summary.source_context;
if (contexts.has(ref.digest)) return contexts.get(ref.digest);
const id = summary.parent_evidence_id;
const facet = async (name) => {
let offset = 0,
rows = [];
while (true) {
const result = await page(
id,
{ kind: "native-context", facet: name, offset, limit: 512 },
`${phase}:context:${name}`,
);
assert.equal(result.source_context.digest, ref.digest);
if (result.items.rows === null) return null;
for (const row of result.items.rows) {
assert.equal(row.source_index, rows.length);
rows.push(
name === "limitations" || name === "result-limitations"
? row.text
: row.record,
);
}
if (result.coverage.exhausted) return rows;
assert.ok(result.coverage.next_offset > offset);
offset = result.coverage.next_offset;
}
};
const overview = (await facet("overview"))[0];
const profileId = overview.analysis_profile_digest;
if (!profiles.has(profileId))
profiles.set(profileId, (await facet("analysis-profile"))[0]);
const { analysis_profile_digest: _digest, ...metadata } = overview;
const context = {
...metadata,
analysis_profile: profiles.get(profileId),
limitations: await facet("limitations"),
result_limitations: await facet("result-limitations"),
};
assert.equal(context.limitations.length, ref.evidence_limitation_count);
assert.equal(
context.result_limitations?.length ?? null,
ref.result_limitation_count,
);
assert.equal(
digestCanonicalValue(
{ ...context, subject: evidenceSubjectIdentity(context.subject) },
"Native source context",
),
ref.digest,
);
contexts.set(ref.digest, context);
return context;
};
const decodeGraphRow = (row, messages) => {
if (row.status === "empty-level") return null;
const { level: _level, source_index: _index, ...node } = row;
if (row.status === "error") return node;
const { limitation_ids, relationship_coverage: coverage, ...rest } = node;
const {
diagnostic_ids,
limitation_ids: coverageIds,
reason_id,
...coverageRest
} = coverage;
return {
...rest,
...(limitation_ids === undefined
? {}
: { limitations: limitation_ids.map((id) => messages[id]) }),
relationship_coverage: {
...coverageRest,
...(diagnostic_ids === undefined
? {}
: { diagnostics: diagnostic_ids.map((id) => messages[id]) }),
...(coverageIds === undefined
? {}
: { limitations: coverageIds.map((id) => messages[id]) }),
...(reason_id === undefined ? {} : { reason: messages[reason_id] }),
},
};
};
const observation = async (name, args, phase) => {
const evidence = await good(
name,
{ ...args, ...(values.mode === "summary" ? { detail: "summary" } : {}) },
phase,
);
if (values.mode === "complete") {
expectedParents.set(evidence.evidence_id, {
result: evidence.normalized_result,
});
return evidence;
}
const summary = evidence.normalized_result,
id = summary.parent_evidence_id;
const context = await readContext(summary, phase);
let selected = summary,
received = 0;
const rows = [],
graph = {};
while (true) {
for (const row of selected.items.rows) {
if (name === "get_call_graph") {
const nodes = (graph[row.level] ??= []);
const node = decodeGraphRow(row, selected.items.messages);
if (node !== null) {
assert.equal(row.source_index, nodes.length);
nodes.push(node);
}
} else {
assert.equal(row.source_index, rows.length);
rows.push(
selected.items.type === "resolutions" ? row.record : row.address,
);
}
received++;
}
if (selected.coverage.exhausted) break;
assert.equal(selected.coverage.next_offset, received);
selected = await page(
id,
{ kind: "native-observation", offset: received, limit: 512 },
`${phase}:facts-page`,
);
assert.equal(selected.source_context.digest, summary.source_context.digest);
}
assert.equal(received, summary.summary.observations);
const result =
name === "get_call_graph"
? { graph, limitations: context.result_limitations }
: name === "resolve_containing_procedure" || name === "procedure_address"
? rows[0]
: rows;
expectedParents.set(id, { result, context });
return { evidence_id: id, normalized_result: result };
};
let opened = false;
const started = performance.now();
try {
({ parseEvidence, evidenceSubjectIdentity } = await load(
"dist/domain/evidence.js",
));
({ digestCanonicalValue } = await load("dist/domain/canonicalDigest.js"));
report.revision = (await git(["rev-parse", "HEAD"])).trim();
report.dirty = (await git(["status", "--porcelain"])).length > 0;
report.sha256 = hash(await readFile(values.target));
account = await mkdtemp(join(tmpdir(), "rea-native-observations-"));
transport = new StdioClientTransport({
command: process.execPath,
args: [join(values.runtime, "scripts/rea.mjs"), "mcp"],
cwd: values.runtime,
env: {
HOME: account,
PATH: process.env.PATH,
NODE_OPTIONS: "--max-old-space-size=3072",
GHIDRA_INSTALL_DIR: values.ghidra,
JAVA_HOME: values.java,
GHIDRA_HEADLESS_MAXMEM: "1536M",
REA_GHIDRA_STARTUP_TIMEOUT_MS: "600000",
REA_MCP_INPUT_SCHEMA_PROFILE: "compact",
REA_LOG_LEVEL: "silent",
},
stderr: "pipe",
});
serverLog = createWriteStream(join(values.output, "server.log"));
serverLogFinished = finished(serverLog);
serverLogFinished.catch(() => {});
transport.stderr?.pipe(serverLog);
client = new Client({ name: "native-observation-views", version: "1" });
call = createRetainedQueryCaller(client, (receipt, event) => {
report.calls.push(receipt);
transcript.push(event);
});
await client.connect(transport);
const catalog = await client.listTools();
assert.equal(catalog.nextCursor, undefined);
await writeFile(join(values.output, "catalog.json"), JSON.stringify(catalog));
const definitions = new Map(catalog.tools.map((tool) => [tool.name, tool]));
const original = client.callTool.bind(client);
client.callTool = (input, options) =>
original(input, {
...options,
timeout: 600000,
toolDefinition: definitions.get(input.name),
});
const open = await call(
"open_binary",
{ path: values.target, provider_id: "ghidra" },
"open",
);
assert.notEqual(open.isError, true, JSON.stringify(open));
opened = true;
let seeds;
for (const phase of ["cold", "warm"]) {
const start = performance.now();
const inventory = await good("list_procedures", {}, `${phase}:inventory`);
const rows = inventory.normalized_result;
assert.ok(Array.isArray(rows));
seeds ??= rows
.filter((row) => !row.procedure?.external && !row.procedure?.thunk)
.slice(0, 21)
.map((row) => row.address);
assert.equal(seeds.length, 21);
const ids = [],
facts = [];
let mostCallers = { address: seeds[0], count: -1 },
mostCallees = { address: seeds[0], count: -1 };
for (const address of seeds) {
for (const [name, args] of [
["procedure_callees", { procedure: address }],
["procedure_callers", { procedure: address }],
["resolve_containing_procedure", { address }],
]) {
const evidence = await observation(name, args, `${phase}:${name}`);
ids.push(evidence.evidence_id);
facts.push(evidence.normalized_result);
if (
name === "procedure_callees" &&
evidence.normalized_result.length > mostCallees.count
)
mostCallees = { address, count: evidence.normalized_result.length };
if (
name === "procedure_callers" &&
evidence.normalized_result.length > mostCallers.count
)
mostCallers = { address, count: evidence.normalized_result.length };
}
}
for (const direction of ["forward", "backward"]) {
const evidence = await observation(
"get_call_graph",
{
address:
direction === "forward" ? mostCallees.address : mostCallers.address,
direction,
},
`${phase}:graph:${direction}`,
);
ids.push(evidence.evidence_id);
facts.push(evidence.normalized_result);
}
const missing = await call(
"procedure_callers",
{
procedure: "rea_unknown_symbol_6426",
...(values.mode === "summary" ? { detail: "summary" } : {}),
},
`${phase}:missing`,
);
assert.equal(missing.isError, true);
const error = JSON.parse(
missing.content.find((item) => item.type === "text").text,
).error;
assert.equal(error.code, "invalid_request");
assert.equal(error.retryable, false);
assert.equal(error.details.diagnostics.diagnostic_scope, "request");
assert.equal(error.details.diagnostics.target_sha256, report.sha256);
const run = {
phase,
inventory_count: rows.length,
seeds,
mostCallers,
mostCallees,
success_evidence_ids: ids,
facts_sha256: digestCanonicalValue(facts, "Investigation facts"),
elapsed_ms: performance.now() - start,
};
await writeFile(
join(values.output, `${phase}-facts.json`),
JSON.stringify(facts),
);
if (values.control) {
assert.deepEqual(
facts,
JSON.parse(
await readFile(join(values.control, `${phase}-facts.json`), "utf8"),
),
);
const control = JSON.parse(
await readFile(join(values.control, "report.json"), "utf8"),
);
assert.deepEqual(
ids,
control.runs.find((item) => item.phase === phase).success_evidence_ids,
);
}
report.runs.push(run);
}
const exported = await call(
"export_evidence_bundle",
{ path: join(values.output, "complete-evidence.json") },
"export",
);
assert.notEqual(exported.isError, true, JSON.stringify(exported));
const bundle = JSON.parse(
await readFile(join(values.output, "complete-evidence.json"), "utf8"),
);
for (const [id, expected] of expectedParents) {
const evidence = parseEvidence(
bundle.records.find((record) => record.evidence_id === id),
);
assert.equal(evidence.subject.digest.sha256, report.sha256);
assert.deepEqual(evidence.normalized_result, expected.result);
if (expected.context)
assert.deepEqual(expected.context, {
subject: evidence.subject,
provider: evidence.provider,
analysis_profile: evidence.analysis_profile,
authority: evidence.authority,
environment: evidence.environment,
limitations: evidence.limitations,
result_limitations:
evidence.operation === "get_call_graph"
? evidence.normalized_result.limitations
: null,
});
}
report.verified_parent_count = expectedParents.size;
report.materialized_contexts = contexts.size;
report.materialized_profiles = profiles.size;
const close = await call("close_binary", {}, "close");
assert.notEqual(close.isError, true, JSON.stringify(close));
opened = false;
assert.equal(hash(await readFile(values.target)), report.sha256);
report.status = "passed";
} catch (cause) {
report.status = "failed";
report.failure = String(cause?.stack ?? cause);
process.exitCode = 1;
} finally {
if (opened)
try {
const close = await call("close_binary", {}, "cleanup");
assert.notEqual(close.isError, true);
} catch (cause) {
report.close_failure = String(cause);
process.exitCode = 1;
}
report.elapsed_ms = performance.now() - started;
if (
!(await finalizeRetainedQueryReceipt({
report,
client,
transport,
account,
serverLog,
serverLogFinished,
output: values.output,
transcript,
}))
)
process.exitCode = 1;
}
@@ -45,18 +45,28 @@ export const finalizeRetainedQueryReceipt = async ({
}) => {
report.cleanup_failures = [];
await Promise.all([
attempt(report.cleanup_failures, "client", () => client.close()),
attempt(report.cleanup_failures, "transport", () => transport.close()),
...(client === undefined
? []
: [attempt(report.cleanup_failures, "client", () => client.close())]),
...(transport === undefined
? []
: [
attempt(report.cleanup_failures, "transport", () =>
transport.close(),
),
]),
]);
await attempt(report.cleanup_failures, "server-log", async () => {
if (!serverLog.writableEnded) serverLog.end();
await serverLogFinished;
});
if (report.cleanup_failures.length === 0)
if (serverLog !== undefined)
await attempt(report.cleanup_failures, "server-log", async () => {
if (!serverLog.writableEnded) serverLog.end();
await serverLogFinished;
});
if (account !== undefined && report.cleanup_failures.length === 0)
await attempt(report.cleanup_failures, "workspace", () =>
rm(account, { recursive: true }),
);
if (report.cleanup_failures.length > 0) report.retained_workspace = account;
if (account !== undefined && report.cleanup_failures.length > 0)
report.retained_workspace = account;
return publishRetainedQueryReceipt({ report, output, transcript, stdout });
};
@@ -180,4 +180,24 @@ assert.match(report.cleanup_failures[0],/client:.*close failed/);
assert.equal(report.retained_workspace,account);assert.equal(serverLog.writableEnded,true);
assert.equal((await stat(account)).isDirectory(),true);
assert.deepEqual(await saved(),report);assert.deepEqual(JSON.parse(chunks.join('')),report);
''')
def test_setup_failure_without_acquired_resources_still_publishes_receipts(self):
self.run_finalization('''
assert.equal(await finalizeRetainedQueryReceipt({report,output,stdout,transcript:[]}),true);
assert.deepEqual(report.cleanup_failures,[]);
assert.deepEqual((await saved()).failure,originalFailure);
assert.deepEqual(JSON.parse(await readFile(join(output,'transcript.json'),'utf8')),[]);
assert.ok((await stat(account)).isDirectory());
''')
def test_partial_setup_closes_acquired_transport_and_releases_its_workspace(self):
self.run_finalization('''
let closed=0;
assert.equal(await finalizeRetainedQueryReceipt({report,output,stdout,account,
transport:{close:async()=>{closed++;}}}),true);
assert.equal(closed,1);
await assert.rejects(stat(account),{code:'ENOENT'});
assert.deepEqual((await saved()).failure,originalFailure);
assert.deepEqual(report.cleanup_failures,[]);
''')
+4 -3
View File
@@ -1,3 +1,4 @@
import type { NativeCallGraph } from "../domain/native/nativeCallGraph.js";
import { nativeProcedureInputSchema } from "../domain/native/nativeProcedureInput.js";
import { CALL_RELATIONSHIP_TRAVERSALS } from "./CallRelationshipTraversal.js";
import { z } from "zod";
@@ -608,7 +609,7 @@ export class EnhancedTools {
address: current.address,
status: "error",
error: projectAnalysisError(result.error),
});
} satisfies NativeCallGraph["graph"][string][number]);
continue;
}
const related = parseRelatedAddresses(result.value.result, relation);
@@ -617,7 +618,7 @@ export class EnhancedTools {
address: current.address,
status: "error",
error: projectAnalysisError(related.error),
});
} satisfies NativeCallGraph["graph"][string][number]);
continue;
}
for (const limitation of result.value.limitations)
@@ -654,7 +655,7 @@ export class EnhancedTools {
...(nodeLimitations.length === 0
? {}
: { limitations: nodeLimitations }),
});
} satisfies NativeCallGraph["graph"][string][number]);
for (const address of related.value) {
if (!discovered.has(address)) {
discovered.add(address);
@@ -1,3 +1,4 @@
import { isNativeObservationOperation } from "../../domain/native/nativeObservation.js";
import { setImmediate } from "node:timers/promises";
import { dispatchQueuedInterrupts } from "../../process/queuedInterrupts.js";
import { readImmutableEvidenceBundle } from "../EvidenceBundleFiles.js";
@@ -202,7 +203,9 @@ const projectResolvedAnalysisView = (
evidenceId: parent.evidence_id,
operation: parent.operation,
provider: parent.provider,
analysisProfileDigest: parent.analysis_profile?.digest ?? null,
analysisProfile: parent.analysis_profile,
authority: parent.authority,
environment: parent.environment,
normalizedResult:
immutableJsonSnapshotMember(parent, "normalized_result") ??
parent.normalized_result,
@@ -372,6 +375,11 @@ const createAnalysisViewEvidence = (
result.parent_operation === "analyze_function")
? result
: undefined;
const nativeObservationView =
result.kind === "native-observation" ||
result.kind === "native-context" ||
(result.kind === "summary" &&
isNativeObservationOperation(result.parent_operation));
return createEvidence(
target,
ANALYSIS_VIEW_PROVIDER,
@@ -396,7 +404,7 @@ const createAnalysisViewEvidence = (
: "derived",
authority: parent.authority,
limitations: result.limitations,
...(nativeFunctionView !== undefined &&
...((nativeFunctionView !== undefined || nativeObservationView) &&
parent.relationship_coverage !== null
? { relationshipCoverage: parent.relationship_coverage }
: {}),
@@ -0,0 +1,543 @@
import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { nativeGraphViewItemsSchema } from "../../domain/analysisView/nativeObservationSchemas.js";
import { expect, it } from "vitest";
import {
createEvidence,
rebindEvidenceToTargetPath,
type Evidence,
} from "../../domain/evidence.js";
import { createAnalysisProfile } from "../../domain/analysisProfile.js";
import { ghidraFunctionIdentity } from "../../domain/ghidraValues.fixture.js";
import {
nativeCallGraphSchema,
type NativeCallGraph,
} from "../../domain/native/nativeCallGraph.js";
import {
analysisViewResultSchema,
type AnalysisViewRequest,
type AnalysisViewResult,
} from "../../domain/analysisView/analysisView.js";
import { inspectAnalysisView } from "./AnalysisViewService.js";
const provider = { id: "fixture", name: "Fixture provider", version: "1" };
const parent = (
operation: string,
result: unknown,
limitations = ["Source caveat"],
profileValue = 1,
) =>
createEvidence(
{
path: "/selected/fixture.elf",
sha256: "a".repeat(64),
format: "elf",
architecture: "x86_64",
},
provider,
{
operation,
parameters: { procedure: "selected_symbol" },
result,
limitations,
analysisProfile: createAnalysisProfile(provider, {
profile: profileValue,
}),
...(operation === "procedure_callees"
? {
relationshipCoverage: {
status: "partial" as const,
limitations: ["Indirect calls remain unknown"],
limited_directions: ["callees"] as ["callees"],
},
}
: {}),
},
{ immutable: true },
);
const project = async (source: Evidence, view: AnalysisViewRequest) => {
const result = await inspectAnalysisView({
source: { kind: "inline", evidence: source },
view,
});
if (!result.ok) throw result.error;
return {
evidence: result.value,
value: analysisViewResultSchema.parse(result.value.normalized_result),
};
};
const contextDigest = (value: AnalysisViewResult): string => {
if (!("source_context" in value))
throw new Error("Expected native source context");
return value.source_context.digest;
};
const graph = (): NativeCallGraph =>
nativeCallGraphSchema.parse({
graph: {
"2": [
{
address: "ram:0x0010",
status: "ok",
calls: ["ram:0x0020"],
relationship_coverage: {
status: "complete",
diagnostics: ["Shared diagnostic 雪🧭"],
},
},
],
"10": [
{
address: "ram:0x0010",
status: "ok",
calls: [],
limitations: [],
relationship_coverage: {
status: "unknown",
reason: "Shared caveat",
limited_directions: ["callers"],
diagnostics: ["Shared diagnostic 雪🧭"],
},
},
],
"01": [
{
address: "ram:0x0010",
status: "ok",
calls: ["ram:0x0020", "ram:0x0020"],
limitations: ["Shared caveat", "Shared caveat"],
relationship_coverage: {
status: "partial",
limitations: ["Shared caveat", "Shared caveat"],
limited_directions: ["callees"],
diagnostics: ["Shared diagnostic 雪🧭"],
},
},
],
},
limitations: ["Graph caveat", "Graph caveat"],
});
it("returns useful caller/callee facts with exact source context identity and partial coverage", async () => {
const source = parent("procedure_callees", [
"ram:0x0010",
"ram:0x0010",
"ram:0x0020",
]);
const before = JSON.stringify(source);
const summary = await project(source, { kind: "summary" });
expect(summary.value).toMatchObject({
kind: "summary",
parent_evidence_id: source.evidence_id,
source_provider: source.provider,
source_parameters: source.parameters,
source_analysis_profile_digest: source.analysis_profile?.digest,
source_context: {
evidence_limitation_count: 1,
result_limitation_count: null,
},
summary: { observations: 3 },
items: {
type: "addresses",
rows: [
{ source_index: 0, address: "ram:0x0010" },
{ source_index: 1, address: "ram:0x0010" },
{ source_index: 2, address: "ram:0x0020" },
],
},
});
expect(summary.evidence.relationship_coverage).toEqual(
source.relationship_coverage,
);
const page = await project(source, {
kind: "native-observation",
offset: 1,
limit: 1,
});
expect(page.value).toMatchObject({
items: { rows: [{ source_index: 1, address: "ram:0x0010" }] },
coverage: { total: 3, next_offset: 2, exhausted: false },
});
expect(page.value).toHaveProperty(
"source_context.digest",
contextDigest(summary.value),
);
expect(JSON.stringify(summary.value)).not.toContain('"analysis_profile":');
expect(JSON.stringify(source)).toBe(before);
});
it("pages complete context fields and changes its digest when source profile or caveats change", async () => {
const source = parent("procedure_callers", []);
const overview = await project(source, {
kind: "native-context",
facet: "overview",
offset: 0,
limit: 1,
});
expect(overview.value).toMatchObject({
items: {
type: "overview",
rows: [
{
record: {
subject: source.subject,
provider,
analysis_profile_digest: source.analysis_profile?.digest,
},
},
],
},
});
const profile = await project(source, {
kind: "native-context",
facet: "analysis-profile",
offset: 0,
limit: 1,
});
expect(profile.value).toMatchObject({
items: { rows: [{ source_index: 0, record: source.analysis_profile }] },
});
const caveat = await project(source, {
kind: "native-context",
facet: "limitations",
offset: 0,
limit: 1,
});
expect(caveat.value).toMatchObject({
items: { rows: [{ source_index: 0, text: "Source caveat" }] },
});
for (const changed of [
parent("procedure_callers", [], ["Changed caveat"]),
parent("procedure_callers", [], ["Source caveat"], 2),
]) {
const changedView = await project(changed, { kind: "summary" });
expect(changedView.value).not.toHaveProperty(
"source_context.digest",
contextDigest(overview.value),
);
}
});
it("keeps absent profile and inapplicable result caveats distinct from observed empty data", async () => {
const source = createEvidence(undefined, provider, {
operation: "procedure_address",
parameters: {},
result: "0x0000",
});
expect(
(
await project(source, {
kind: "native-context",
facet: "analysis-profile",
offset: 0,
limit: 64,
})
).value,
).toMatchObject({ items: { rows: [{ source_index: 0, record: null }] } });
expect(
(
await project(source, {
kind: "native-context",
facet: "result-limitations",
offset: 0,
limit: 64,
})
).value,
).toMatchObject({
source_context: { result_limitation_count: null },
items: { rows: null },
unknowns: expect.arrayContaining([
expect.stringContaining("not applicable"),
]),
});
expect(
(
await project(parent("procedure_callers", []), {
kind: "native-observation",
offset: 0,
limit: 64,
})
).value,
).toMatchObject({
summary: { observations: 0 },
items: { rows: [] },
coverage: { status: "empty", exhausted: true },
});
});
it.each([true, false])(
"preserves the complete containing-procedure outcome (found=%s)",
async (found) => {
const report = found
? {
query_address: "0x401001",
found: true,
procedure: ghidraFunctionIdentity(),
}
: {
query_address: "0x9999",
found: false,
procedure: null,
reason: "not_in_procedure",
};
const result = await project(
parent("resolve_containing_procedure", report),
{ kind: "summary" },
);
expect(result.value).toMatchObject({
summary: { observations: 1 },
items: { rows: [{ source_index: 0, record: report }] },
});
},
);
it("reconstructs every graph field from dictionary pages without changing nulls, duplicate messages or level identities", async () => {
const report = graph();
const source = parent("get_call_graph", report);
const decoded: NativeCallGraph["graph"] = {};
for (let offset = 0; offset < 3; offset++) {
const { value } = await project(source, {
kind: "native-observation",
offset,
limit: 1,
});
if (
value.kind !== "native-observation" ||
value.parent_operation !== "get_call_graph"
)
throw new Error("Expected graph page");
const { rows, messages } = value.items;
expect(rows).toHaveLength(1);
const row = rows[0]!;
expect(row.source_index).toBe(0);
expect(row.level).toBe(["01", "10", "2"][offset]);
expect(value.coverage).toMatchObject({ total: 3, exhausted: offset === 2 });
if (row.status !== "ok") throw new Error("Expected recorded node");
const coverage = row.relationship_coverage;
const diagnostics =
coverage.diagnostic_ids === undefined
? {}
: { diagnostics: coverage.diagnostic_ids.map((i) => messages[i]!) };
const directions =
"limited_directions" in coverage &&
coverage.limited_directions !== undefined
? { limited_directions: coverage.limited_directions }
: {};
const originalCoverage =
coverage.status === "complete"
? { status: "complete" as const, ...diagnostics }
: coverage.status === "partial"
? {
status: "partial" as const,
limitations: coverage.limitation_ids.map((i) => messages[i]!),
...directions,
...diagnostics,
}
: {
status: "unknown" as const,
reason: messages[coverage.reason_id]!,
...directions,
...diagnostics,
};
decoded[row.level] = [
{
address: row.address,
status: row.status,
calls: row.calls,
relationship_coverage: originalCoverage,
...(row.limitation_ids === undefined
? {}
: { limitations: row.limitation_ids.map((i) => messages[i]!) }),
},
];
expect(messages).toHaveLength(coverage.status === "complete" ? 1 : 2);
}
expect(decoded).toEqual(report.graph);
const caveats = await project(source, {
kind: "native-context",
facet: "result-limitations",
offset: 1,
limit: 1,
});
expect(caveats.value).toMatchObject({
source_context: { result_limitation_count: 2 },
items: { rows: [{ source_index: 1, text: "Graph caveat" }] },
});
});
it("rejects malformed unselected source facts and preserves exact source paths", async () => {
const malformed = {
...graph(),
graph: {
"0": [
{
address: "0x10",
status: "ok",
calls: [],
relationship_coverage: { status: "complete" },
},
{
address: "0x20",
status: "ok",
calls: [42],
relationship_coverage: { status: "complete" },
},
],
},
};
const result = await inspectAnalysisView({
source: { kind: "inline", evidence: parent("get_call_graph", malformed) },
view: { kind: "native-observation", offset: 0, limit: 1 },
});
expect(result).toMatchObject({
ok: false,
error: {
_tag: "AnalysisInputError",
issues: expect.arrayContaining([
expect.objectContaining({
path: ["source", "normalized_result", "graph", "0", 1, "calls", 0],
}),
]),
},
});
});
it("retains failed graph nodes and rejects broken message references", async () => {
const source = graph();
const failure = {
address: "ram:0x0010",
status: "error" as const,
error: projectAnalysisError(
new AnalysisCapabilityUnavailableError(
"fixture",
"procedure_callees",
"No call references",
),
),
};
source.graph.failure = [failure];
const result = await project(parent("get_call_graph", source), {
kind: "native-observation",
offset: 3,
limit: 1,
});
expect(result.value).toMatchObject({
items: {
type: "graph-nodes",
messages: [],
rows: [{ level: "failure", source_index: 0, ...failure }],
},
coverage: { total: 4, exhausted: true },
});
const normal = await project(parent("get_call_graph", graph()), {
kind: "native-observation",
offset: 0,
limit: 1,
});
if (
normal.value.kind !== "native-observation" ||
normal.value.parent_operation !== "get_call_graph"
)
throw new Error("Expected graph page");
expect(
nativeGraphViewItemsSchema.safeParse({
...normal.value.items,
messages: [],
}).success,
).toBe(false);
});
it("keeps graph row identity stable when source object key insertion order changes", async () => {
const report = graph();
report.graph.beta = [report.graph["2"]![0]!];
const first = parent("get_call_graph", report);
const reversed = parent("get_call_graph", {
...report,
graph: Object.fromEntries(Object.entries(report.graph).reverse()),
});
expect(Object.keys(report.graph)).not.toEqual(
Object.keys(Object.fromEntries(Object.entries(report.graph).reverse())),
);
expect(first.evidence_id).toBe(reversed.evidence_id);
expect(
await project(first, { kind: "native-observation", offset: 0, limit: 64 }),
).toEqual(
await project(reversed, {
kind: "native-observation",
offset: 0,
limit: 64,
}),
);
});
it("keeps context identity across authenticated target path aliases while preserving selected paths", async () => {
const original = parent("procedure_callers", []);
const rebound = rebindEvidenceToTargetPath(original, {
path: "/other/alias.elf",
sha256: "a".repeat(64),
format: "elf",
architecture: "x86_64",
});
expect(rebound.evidence_id).toBe(original.evidence_id);
const view = {
kind: "native-context" as const,
facet: "overview" as const,
offset: 0,
limit: 1,
};
const first = await project(original, view);
const second = await project(rebound, view);
expect(contextDigest(second.value)).toBe(contextDigest(first.value));
expect(second.value).toMatchObject({
items: {
rows: [{ record: { subject: { local_path: "/other/alias.elf" } } }],
},
});
expect(second.evidence.subject?.local_path).toBe("/other/alias.elf");
expect(second.evidence.evidence_id).not.toBe(first.evidence.evidence_id);
});
it("pages recorded empty graph levels without inventing nodes or dropping their identity", async () => {
const report = { graph: { absent: [], empty: [] }, limitations: [] };
const result = await project(parent("get_call_graph", report), {
kind: "native-observation",
offset: 1,
limit: 1,
});
expect(result.value).toMatchObject({
items: {
messages: [],
rows: [{ level: "empty", source_index: null, status: "empty-level" }],
},
summary: { observations: 2 },
coverage: { total: 2, exhausted: true },
});
});
it.each([
{ status: "partial" as const, limitations: ["Unresolved indirect edges"] },
{ status: "unknown" as const, reason: "Provider did not establish coverage" },
])(
"keeps $status relationship coverage on every context facet",
async (coverage) => {
const source = createEvidence(undefined, provider, {
operation: "procedure_callees",
parameters: {},
result: [],
relationshipCoverage: coverage,
});
for (const facet of [
"overview",
"analysis-profile",
"limitations",
"result-limitations",
] as const) {
const result = await project(source, {
kind: "native-context",
facet,
offset: 0,
limit: 64,
});
expect(result.evidence.relationship_coverage).toEqual(coverage);
}
},
);
+5 -2
View File
@@ -1,3 +1,4 @@
import { analysisDetailSchema } from "./analysisDetail.js";
import { z } from "zod";
import { enhancedInputSchemas } from "./enhancedInputs.js";
@@ -53,8 +54,10 @@ export const ENHANCED_TOOL_CONTRACTS = [
),
enhanced(
"get_call_graph",
"Resolve one symbol or address to its canonical procedure entry, then traverse the bound provider's caller or callee relationships until the reachable graph is exhausted. Every node uses a canonical address and has an ok/error status; failures use safe typed projections. Unresolved indirect calls may be missing and results are not a whole-program CFG.",
enhancedInputSchemas.get_call_graph,
"Resolve one symbol or address to its canonical procedure entry, then traverse the bound provider's caller or callee relationships until the reachable graph is exhausted. Every node uses a canonical address and has an ok/error status; failures use safe typed projections. Unresolved indirect calls may be missing and results are not a whole-program CFG. Select detail summary for a bounded first node page with shared message references; native-observation pages and native-context fields retain complete source access.",
enhancedInputSchemas.get_call_graph.extend({
detail: analysisDetailSchema,
}),
),
enhanced(
"analyze_swift_types",
+8 -8
View File
@@ -152,8 +152,8 @@ export const OFFICIAL_TOOL_CONTRACTS = [
),
official(
"procedure_address",
"Resolve an unambiguous procedure symbol name or provider-normalized address to its canonical entry address. External address spaces remain explicit.",
z.object({ procedure, document }),
"Resolve an unambiguous procedure symbol name or provider-normalized address to its canonical entry address. External address spaces remain explicit. Select detail summary for selected facts and a context digest; native-observation and native-context views retain complete source access.",
z.object({ procedure, document, detail: analysisDetailSchema }),
),
official(
"procedure_assembly",
@@ -162,13 +162,13 @@ export const OFFICIAL_TOOL_CONTRACTS = [
),
official(
"procedure_callees",
"Return the provider's resolved direct callees for one procedure identified by symbol or address. Unresolved indirect calls may be absent; use analyze_function and typed references to preserve available edge uncertainty. Ghidra terminal-call assumptions that may cut the analyzed body are reported in Evidence limitations.",
z.object({ procedure, document }),
"Return the provider's resolved direct callees for one procedure identified by symbol or address. Unresolved indirect calls may be absent; use analyze_function and typed references to preserve available edge uncertainty. Ghidra terminal-call assumptions that may cut the analyzed body are reported in Evidence limitations. Select detail summary for selected facts and a context digest; native-observation and native-context views retain complete source access.",
z.object({ procedure, document, detail: analysisDetailSchema }),
),
official(
"procedure_callers",
"Return the provider's resolved direct callers for one procedure identified by symbol or address. Results reflect completed static analysis and may omit unresolved indirect references. Ghidra terminal-call assumptions that may cut the analyzed body are reported in Evidence limitations.",
z.object({ procedure, document }),
"Return the provider's resolved direct callers for one procedure identified by symbol or address. Results reflect completed static analysis and may omit unresolved indirect references. Ghidra terminal-call assumptions that may cut the analyzed body are reported in Evidence limitations. Select detail summary for selected facts and a context digest; native-observation and native-context views retain complete source access.",
z.object({ procedure, document, detail: analysisDetailSchema }),
),
official(
"procedure_info",
@@ -199,8 +199,8 @@ export const OFFICIAL_TOOL_CONTRACTS = [
),
official(
"resolve_containing_procedure",
"Resolve an arbitrary address, including an interior instruction or exact external entry, to its provider-analyzed containing procedure. A negative result distinguishes outside segments from not in a procedure and is never guessed from nearby symbols.",
z.object({ address, document }),
"Resolve an arbitrary address, including an interior instruction or exact external entry, to its provider-analyzed containing procedure. A negative result distinguishes outside segments from not in a procedure and is never guessed from nearby symbols. Select detail summary for selected facts and a context digest; native-observation and native-context views retain complete source access.",
z.object({ address, document, detail: analysisDetailSchema }),
),
official(
"search_procedures",
+25 -14
View File
@@ -1,3 +1,8 @@
import {
nativeAddressListSchema,
nativeObservationSourceSchemas,
} from "../domain/native/nativeObservation.js";
import { nativeObservationSummaryViewResultSchemas } from "../domain/analysisView/analysisView.js";
import { literalFeatureMatchSchema } from "../domain/native/literalFeatureMatch.js";
import { byteEntropySchema } from "../domain/native/byteEntropy.js";
import {
@@ -88,10 +93,8 @@ import { relationshipCoverageSchema } from "../domain/relationshipCoverage.js";
import { nativeDispatchMetadataResultSchema } from "../domain/native/objcSwiftMetadata.js";
import { nativeInvestigationTraceSchema } from "../domain/native/nativeInvestigationGraph.js";
import {
addressList,
addressedEntry,
functionDossierOutput,
graphNode,
lifecycleResultOf,
nullableText,
procedureInfoOutput,
@@ -168,10 +171,19 @@ export const officialOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
),
next_address: evidenceResultOf(z.string()),
prev_address: evidenceResultOf(z.string()),
procedure_address: evidenceResultOf(z.string()),
procedure_address: analysisDetailOutputSchemaOf(
nativeObservationSourceSchemas.procedure_address,
nativeObservationSummaryViewResultSchemas.procedure_address,
),
procedure_assembly: evidenceResultOf(z.string()),
procedure_callees: evidenceResultOf(addressList),
procedure_callers: evidenceResultOf(addressList),
procedure_callees: analysisDetailOutputSchemaOf(
nativeObservationSourceSchemas.procedure_callees,
nativeObservationSummaryViewResultSchemas.procedure_callees,
),
procedure_callers: analysisDetailOutputSchemaOf(
nativeObservationSourceSchemas.procedure_callers,
nativeObservationSummaryViewResultSchemas.procedure_callers,
),
procedure_info: procedureInfoOutput,
read_function_instructions: evidenceResultOf(functionInstructionWindowSchema),
read_bytes: evidenceResultOf(nativeByteReadSchema),
@@ -213,8 +225,9 @@ export const officialOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
}),
),
procedure_pseudo_code: evidenceResultOf(nullableText),
resolve_containing_procedure: evidenceResultOf(
containingProcedureResolutionSchema,
resolve_containing_procedure: analysisDetailOutputSchemaOf(
nativeObservationSourceSchemas.resolve_containing_procedure,
nativeObservationSummaryViewResultSchemas.resolve_containing_procedure,
),
search_procedures: analysisDetailOutputSchemaOf(
nativeInventoryRowsSchemas.names,
@@ -235,7 +248,7 @@ export const officialOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
set_comment: evidenceResultOf(z.boolean()),
set_inline_comment: evidenceResultOf(z.boolean()),
unset_bookmark: evidenceResultOf(z.boolean()),
xrefs: evidenceResultOf(addressList),
xrefs: evidenceResultOf(nativeAddressListSchema),
};
const literalTraceOutput = evidenceResultOf(
@@ -345,11 +358,9 @@ export const enhancedOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
failed: z.int().min(0),
}),
),
get_call_graph: evidenceResultOf(
z.strictObject({
graph: z.record(z.string(), z.array(graphNode)),
limitations: z.array(z.string()),
}),
get_call_graph: analysisDetailOutputSchemaOf(
nativeObservationSourceSchemas.get_call_graph,
nativeObservationSummaryViewResultSchemas.get_call_graph,
),
analyze_swift_types: evidenceResultOf(
z.object({
@@ -381,7 +392,7 @@ export const enhancedOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
status: z.literal("resolved"),
name: z.string(),
address: z.string(),
xrefs: addressList,
xrefs: nativeAddressListSchema,
}),
z.object({
status: z.literal("unresolved"),
@@ -18,7 +18,6 @@ import {
PROVIDER_RETRY_ACTIONS,
} from "../domain/providerOperationHealth.js";
import { analysisErrorProjectionSchema } from "./errorSchemas.js";
import { relationshipCoverageSchema } from "../domain/relationshipCoverage.js";
import { ZIP_PACKAGE_FORMATS } from "../domain/zipPackageFormat.js";
import { objcClassImportIdentitySchema } from "../domain/native/objcSwiftMetadata.js";
import { BINARY_SESSION_SUMMARY_OMITTED_FIELDS } from "./sessionStatusContract.js";
@@ -659,7 +658,6 @@ export const sessionProvider = z.discriminatedUnion("detail", [
]);
export const nullableText = z.string().nullable();
export const addressList = z.array(z.string());
export const addressedEntry = z.object({
address: z.string(),
name: z.string(),
@@ -729,19 +727,4 @@ export const objcClassDiscoveryOutput = evidenceResultOf(
}),
);
export const graphNode = z.discriminatedUnion("status", [
z.object({
address: z.string(),
status: z.literal("ok"),
calls: z.array(z.string()),
relationship_coverage: relationshipCoverageSchema,
limitations: z.array(z.string()).optional(),
}),
z.object({
address: z.string(),
status: z.literal("error"),
error: analysisErrorProjectionSchema,
}),
]);
export const functionDossierOutput = evidenceResultOf(functionDossierSchema);
+98 -1
View File
@@ -1,3 +1,16 @@
import {
nativeObservationViewRequestSchema,
nativeContextViewRequestSchema,
nativeObservationItemsSchemas,
nativeContextItemsSchemas,
nativeSourceContextReferenceSchema,
nativeObservationOperationSchema,
} from "./nativeObservationSchemas.js";
import {
NATIVE_OBSERVATION_OPERATIONS,
type NativeObservationOperation,
} from "../native/nativeObservation.js";
import { projectNativeObservationView } from "./nativeObservationView.js";
import {
devicetreeViewRequestSchema,
devicetreeViewItemsSchema,
@@ -375,6 +388,8 @@ const nativeViewSchema = z.strictObject({
/** Caller-selected projection of already completed analysis Evidence. */
export const analysisViewRequestSchema = z.discriminatedUnion("kind", [
nativeObservationViewRequestSchema,
nativeContextViewRequestSchema,
devicetreeViewRequestSchema,
erlangBeamViewRequestSchema,
pythonPycViewRequestSchema,
@@ -534,6 +549,7 @@ const viewResultBase = {
"inspect_model_artifact",
"inspect_gpu_code_objects",
...NATIVE_INVENTORY_OPERATIONS,
...NATIVE_OBSERVATION_OPERATIONS,
"match_native_functions",
]),
parent_digest: digestSchema,
@@ -550,6 +566,79 @@ const summaryViewResultBase = {
...viewResultBase,
} as const;
const nativeObservationSummaryFor = <
Operation extends NativeObservationOperation,
>(
operation: Operation,
) =>
z.strictObject({
...summaryViewResultBase,
...nativeViewSourceContext,
artifact: artifactSchema.nullable(),
parent_operation: z.literal(operation),
source_context: nativeSourceContextReferenceSchema,
summary: z.strictObject({ observations: z.int().nonnegative() }),
items: nativeObservationItemsSchemas[operation],
});
export const nativeObservationSummaryViewResultSchemas = {
procedure_address: nativeObservationSummaryFor("procedure_address"),
procedure_callees: nativeObservationSummaryFor("procedure_callees"),
procedure_callers: nativeObservationSummaryFor("procedure_callers"),
resolve_containing_procedure: nativeObservationSummaryFor(
"resolve_containing_procedure",
),
get_call_graph: nativeObservationSummaryFor("get_call_graph"),
};
const nativeObservationSummaryViewResultSchema = z.discriminatedUnion(
"parent_operation",
[
nativeObservationSummaryViewResultSchemas.procedure_address,
nativeObservationSummaryViewResultSchemas.procedure_callees,
nativeObservationSummaryViewResultSchemas.procedure_callers,
nativeObservationSummaryViewResultSchemas.resolve_containing_procedure,
nativeObservationSummaryViewResultSchemas.get_call_graph,
],
);
const nativeObservationPageFor = <Operation extends NativeObservationOperation>(
operation: Operation,
) =>
nativeObservationSummaryFor(operation)
.omit({ kind: true, view: true })
.extend({
kind: z.literal("native-observation"),
view: nativeObservationViewRequestSchema,
});
const nativeObservationPageViewResultSchema = z.discriminatedUnion(
"parent_operation",
[
nativeObservationPageFor("procedure_address"),
nativeObservationPageFor("procedure_callees"),
nativeObservationPageFor("procedure_callers"),
nativeObservationPageFor("resolve_containing_procedure"),
nativeObservationPageFor("get_call_graph"),
],
);
const nativeContextFor = <Facet extends keyof typeof nativeContextItemsSchemas>(
facet: Facet,
) =>
z.strictObject({
...viewResultBase,
...nativeViewSourceContext,
kind: z.literal("native-context"),
artifact: artifactSchema.nullable(),
parent_operation: nativeObservationOperationSchema,
source_context: nativeSourceContextReferenceSchema,
facet: z.literal(facet),
view: nativeContextViewRequestSchema.extend({ facet: z.literal(facet) }),
items: nativeContextItemsSchemas[facet],
});
const nativeContextViewResultSchema = z.discriminatedUnion("facet", [
nativeContextFor("overview"),
nativeContextFor("analysis-profile"),
nativeContextFor("limitations"),
nativeContextFor("result-limitations"),
]);
export const byteEntropySummaryViewResultSchema = z.strictObject({
...summaryViewResultBase,
summary: byteEntropySummarySchema,
@@ -980,6 +1069,8 @@ export const sourceToBundlePageViewResultSchema = z.strictObject({
/** Projected facts for one selected view of retained analysis Evidence. */
export const analysisViewResultSchema = z.discriminatedUnion("kind", [
nativeObservationPageViewResultSchema,
nativeContextViewResultSchema,
javaScriptRoutesViewResultSchema,
sourceToBundlePageViewResultSchema,
z.strictObject({
@@ -1033,6 +1124,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
view: environmentCoverageViewRequestSchema,
}),
z.discriminatedUnion("parent_operation", [
nativeObservationSummaryViewResultSchema,
erlangBeamSummaryViewResultSchema,
devicetreeSummaryViewResultSchema,
pythonPycSummaryViewResultSchema,
@@ -1286,7 +1378,9 @@ export interface AnalysisViewParent {
readonly operation: string;
readonly normalizedResult: unknown;
readonly provider?: Evidence["provider"];
readonly analysisProfileDigest?: string | null;
readonly analysisProfile?: Evidence["analysis_profile"];
readonly authority?: Evidence["authority"];
readonly environment?: Evidence["environment"];
/** The owner proved normalizedResult belongs to a sealed, deep-frozen Evidence snapshot. */
readonly sealedResult?: boolean;
/** Preserve the authenticated source identity, including explicit absence. */
@@ -1362,6 +1456,9 @@ const parentArtifactPath = (parent: AnalysisViewParent): string => {
/** Named source owners for views that need no extra decoder or accumulator. */
const retainedViewProjectors = new Map<string, typeof projectWasmArtifactView>([
...NATIVE_OBSERVATION_OPERATIONS.map(
(operation) => [operation, projectNativeObservationView] as const,
),
["inspect_erlang_beam", projectErlangBeamView],
["inspect_devicetree", projectDevicetreeView],
["inspect_native_exports", projectNativeExportsView],
@@ -96,7 +96,7 @@ export const projectCommandObservationView = (
parent_digest: parent.evidenceId.slice(3),
artifact,
source_provider: parent.provider ?? null,
source_analysis_profile_digest: parent.analysisProfileDigest ?? null,
source_analysis_profile_digest: parent.analysisProfile?.digest ?? null,
source_parameters: parameters.data,
coverage: completeWithinViewCoverage(1, 1),
limitations: boundedViewMessages(
@@ -0,0 +1,98 @@
import { z } from "zod";
import type { NativeCallGraph } from "../native/nativeCallGraph.js";
import type { RelationshipCoverage } from "../relationshipCoverage.js";
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
import {
nativeGraphViewItemsSchema,
referencedRelationshipCoverageSchema,
} from "./nativeObservationSchemas.js";
type GraphItems = z.output<typeof nativeGraphViewItemsSchema>;
type Coverage = z.output<typeof referencedRelationshipCoverageSchema>;
const referenceCoverage = (
source: RelationshipCoverage,
intern: (message: string) => number,
): Coverage => {
const diagnostic =
source.diagnostics === undefined
? {}
: { diagnostic_ids: source.diagnostics.map(intern) };
if (source.status === "complete")
return { status: "complete", ...diagnostic };
const directions =
source.limited_directions === undefined
? {}
: { limited_directions: source.limited_directions };
return source.status === "partial"
? {
status: "partial",
limitation_ids: source.limitations.map(intern),
...directions,
...diagnostic,
}
: {
status: "unknown",
reason_id: intern(source.reason),
...directions,
...diagnostic,
};
};
/** Object levels have canonical key order; row identity is (parent, level, source_index). */
export const projectNativeGraphRows = (
source: NativeCallGraph,
view: { readonly offset: number; readonly limit: number },
): { items: GraphItems; total: number } => {
const messages: string[] = [];
const known = new Map<string, number>();
const intern = (message: string): number => {
const prior = known.get(message);
if (prior !== undefined) return prior;
const id = messages.length;
messages.push(message);
known.set(message, id);
return id;
};
const rows: GraphItems["rows"] = [];
let total = 0;
for (const level of Object.keys(source.graph).sort(
compareUnicodeCodePoints,
)) {
const nodes = source.graph[level]!;
if (nodes.length === 0) {
if (total >= view.offset && rows.length < view.limit)
rows.push({ level, source_index: null, status: "empty-level" });
total++;
continue;
}
const begin = Math.max(0, view.offset - total);
const end = Math.min(
nodes.length,
begin + Math.max(0, view.limit - rows.length),
);
for (let index = begin; index < end; index++) {
const node = nodes[index]!;
rows.push(
node.status === "error"
? { level, source_index: index, ...node }
: {
level,
source_index: index,
address: node.address,
status: node.status,
calls: node.calls,
relationship_coverage: referenceCoverage(
node.relationship_coverage,
intern,
),
...(node.limitations === undefined
? {}
: { limitation_ids: node.limitations.map(intern) }),
},
);
}
total += nodes.length;
}
return { items: { type: "graph-nodes", messages, rows }, total };
};
@@ -116,7 +116,7 @@ export const projectNativeInventoryView = (
parent_digest: parent.evidenceId.slice(3),
artifact: parent.artifact ?? null,
source_provider: parent.provider ?? null,
source_analysis_profile_digest: parent.analysisProfileDigest ?? null,
source_analysis_profile_digest: parent.analysisProfile?.digest ?? null,
source_parameters: jsonValueSchema.parse(parent.parameters ?? null),
limitations: boundedViewMessages(
parent.limitations,
@@ -241,7 +241,7 @@ export const projectNativeLoadImageView = (
parent_digest: parent.evidenceId.slice(3),
artifact: parent.artifact ?? null,
source_provider: parent.provider ?? null,
source_analysis_profile_digest: parent.analysisProfileDigest ?? null,
source_analysis_profile_digest: parent.analysisProfile?.digest ?? null,
source_parameters: jsonValueSchema.parse(parent.parameters ?? null),
limitations: boundedViewMessages(
[...parent.limitations, ...limitations],
@@ -0,0 +1,199 @@
import { NATIVE_OBSERVATION_OPERATIONS } from "../native/nativeObservation.js";
import { z } from "zod";
import { digestSchema } from "../digests.js";
import { evidenceSchema } from "../evidence.js";
import { containingProcedureResolutionSchema } from "../hopperValues.js";
import {
completeRelationshipCoverageSchema,
partialRelationshipCoverageSchema,
unknownRelationshipCoverageSchema,
} from "../relationshipCoverage.js";
import { analysisErrorProjectionSchema } from "../../contracts/errorSchemas.js";
export const nativeObservationOperationSchema = z.enum(
NATIVE_OBSERVATION_OPERATIONS,
);
const paging = {
offset: z.int().nonnegative().default(0),
limit: z.int().positive().default(64),
};
export const nativeObservationViewRequestSchema = z.strictObject({
kind: z.literal("native-observation"),
...paging,
});
export const nativeContextViewRequestSchema = z.strictObject({
kind: z.literal("native-context"),
facet: z
.enum(["overview", "analysis-profile", "limitations", "result-limitations"])
.default("overview"),
...paging,
});
export const nativeSourceContextSchema = z.strictObject({
subject: evidenceSchema.shape.subject,
provider: evidenceSchema.shape.provider,
analysis_profile: evidenceSchema.shape.analysis_profile,
authority: evidenceSchema.shape.authority,
environment: evidenceSchema.shape.environment,
limitations: evidenceSchema.shape.limitations,
result_limitations: z.array(z.string()).nullable(),
});
export type NativeSourceContext = z.infer<typeof nativeSourceContextSchema>;
export const nativeSourceContextReferenceSchema = z.strictObject({
materialization: z.literal("reference"),
digest: digestSchema,
evidence_limitation_count: z.int().nonnegative(),
result_limitation_count: z.int().nonnegative().nullable(),
});
const sourceIndex = z.int().nonnegative();
const ids = z.array(sourceIndex);
const diagnostics = ids.min(1).exactOptional();
export const referencedRelationshipCoverageSchema = z.discriminatedUnion(
"status",
[
completeRelationshipCoverageSchema
.omit({ diagnostics: true })
.extend({ diagnostic_ids: diagnostics }),
partialRelationshipCoverageSchema
.omit({ diagnostics: true, limitations: true })
.extend({ limitation_ids: ids.min(1), diagnostic_ids: diagnostics }),
unknownRelationshipCoverageSchema
.omit({ diagnostics: true, reason: true })
.extend({ reason_id: sourceIndex, diagnostic_ids: diagnostics }),
],
);
const graphRowBase = {
level: z.string(),
source_index: sourceIndex,
address: z.string(),
};
const graphRowsSchema = z.array(
z.discriminatedUnion("status", [
z.strictObject({
level: z.string(),
source_index: z.null(),
status: z.literal("empty-level"),
}),
z.strictObject({
...graphRowBase,
status: z.literal("ok"),
calls: z.array(z.string()),
relationship_coverage: referencedRelationshipCoverageSchema,
limitation_ids: ids.exactOptional(),
}),
z.strictObject({
...graphRowBase,
status: z.literal("error"),
error: analysisErrorProjectionSchema,
}),
]),
);
export const nativeGraphViewItemsSchema = z
.strictObject({
type: z.literal("graph-nodes"),
messages: z.array(z.string()),
rows: graphRowsSchema,
})
.superRefine((items, context) => {
const valid = (value: number, path: PropertyKey[]) => {
if (value >= items.messages.length)
context.addIssue({
code: "custom",
path,
message: "Message reference is outside this page's dictionary",
});
};
items.rows.forEach((row, index) => {
if (row.status !== "ok") return;
row.limitation_ids?.forEach((value, i) =>
valid(value, ["rows", index, "limitation_ids", i]),
);
const coverage = row.relationship_coverage;
coverage.diagnostic_ids?.forEach((value, i) =>
valid(value, [
"rows",
index,
"relationship_coverage",
"diagnostic_ids",
i,
]),
);
if (coverage.status === "partial")
coverage.limitation_ids.forEach((value, i) =>
valid(value, [
"rows",
index,
"relationship_coverage",
"limitation_ids",
i,
]),
);
if (coverage.status === "unknown")
valid(coverage.reason_id, [
"rows",
index,
"relationship_coverage",
"reason_id",
]);
});
});
const addresses = z.strictObject({
type: z.literal("addresses"),
rows: z.array(
z.strictObject({ source_index: sourceIndex, address: z.string() }),
),
});
const resolutions = z.strictObject({
type: z.literal("resolutions"),
rows: z.array(
z.strictObject({
source_index: sourceIndex,
record: containingProcedureResolutionSchema,
}),
),
});
export const nativeObservationItemsSchemas = {
procedure_address: addresses,
procedure_callees: addresses,
procedure_callers: addresses,
resolve_containing_procedure: resolutions,
get_call_graph: nativeGraphViewItemsSchema,
};
export const nativeContextItemsSchemas = {
overview: z.strictObject({
type: z.literal("overview"),
rows: z.array(
z.strictObject({
source_index: sourceIndex,
record: nativeSourceContextSchema
.omit({
analysis_profile: true,
limitations: true,
result_limitations: true,
})
.extend({ analysis_profile_digest: digestSchema.nullable() }),
}),
),
}),
"analysis-profile": z.strictObject({
type: z.literal("analysis-profile"),
rows: z.array(
z.strictObject({
source_index: sourceIndex,
record: evidenceSchema.shape.analysis_profile,
}),
),
}),
limitations: z.strictObject({
type: z.literal("limitations"),
rows: z.array(
z.strictObject({ source_index: sourceIndex, text: z.string() }),
),
}),
"result-limitations": z.strictObject({
type: z.literal("result-limitations"),
rows: z
.array(z.strictObject({ source_index: sourceIndex, text: z.string() }))
.nullable(),
}),
};
@@ -0,0 +1,326 @@
import type { AnalysisError } from "../analysisErrorBase.js";
import { evidenceSubjectIdentity } from "../evidence.js";
import { digestCanonicalValue } from "../canonicalDigest.js";
import { jsonValueSchema } from "../jsonValue.js";
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
import { err, ok, type Result } from "../result.js";
import {
nativeAddressListSchema,
nativeObservationSourceSchemas,
type NativeObservation,
} from "../native/nativeObservation.js";
import type {
AnalysisViewParent,
AnalysisViewRequest,
UnsignedAnalysisView,
} from "./analysisView.js";
import { createAnalysisViewResultReader } from "./analysisViewParent.js";
import { analysisViewInputError } from "./analysisViewErrors.js";
import {
pageViewCoverage,
completeWithinViewCoverage,
} from "./analysisViewCoverage.js";
import {
nativeSourceContextSchema,
type NativeSourceContext,
} from "./nativeObservationSchemas.js";
import { projectNativeGraphRows } from "./nativeGraphView.js";
const readAddresses = createAnalysisViewResultReader(nativeAddressListSchema);
const readAddress = createAnalysisViewResultReader(
nativeObservationSourceSchemas.procedure_address,
);
const readResolution = createAnalysisViewResultReader(
nativeObservationSourceSchemas.resolve_containing_procedure,
);
const readGraph = createAnalysisViewResultReader(
nativeObservationSourceSchemas.get_call_graph,
);
const readSource = (
parent: AnalysisViewParent,
): Result<NativeObservation, AnalysisError> => {
switch (parent.operation) {
case "procedure_callees":
case "procedure_callers": {
const read = readAddresses(parent);
return read.ok
? ok({ operation: parent.operation, result: read.value })
: read;
}
case "procedure_address": {
const read = readAddress(parent);
return read.ok
? ok({ operation: parent.operation, result: read.value })
: read;
}
case "resolve_containing_procedure": {
const read = readResolution(parent);
return read.ok
? ok({ operation: parent.operation, result: read.value })
: read;
}
case "get_call_graph": {
const read = readGraph(parent);
return read.ok
? ok({ operation: parent.operation, result: read.value })
: read;
}
default:
return err(
analysisViewInputError([
{
path: ["source"],
reason: "invalid_value",
message:
"This parent does not record a supported native observation.",
},
]),
);
}
};
const readContext = (
parent: AnalysisViewParent,
source: NativeObservation,
): Result<NativeSourceContext, AnalysisError> => {
const values = {
subject: parent.subject,
provider: parent.provider,
analysis_profile: parent.analysisProfile,
authority: parent.authority,
environment: parent.environment,
limitations: parent.limitations,
result_limitations:
source.operation === "get_call_graph" ? source.result.limitations : null,
};
const parsed = nativeSourceContextSchema.safeParse(values);
return parsed.success
? ok(parsed.data)
: err(
analysisInputErrorFromIssues(
"inspect_analysis_view",
parsed.error.issues.map((issue) => ({
...issue,
path: ["source", ...issue.path],
})),
{ source: values },
{ cause: parsed.error },
),
);
};
const selectedRows = <Value>(
values: readonly Value[],
selection: { readonly offset: number; readonly limit: number },
) =>
values
.slice(selection.offset, selection.offset + selection.limit)
.map((value, index) => ({ source_index: selection.offset + index, value }));
const observationRows = (
source: NativeObservation,
view: { readonly offset: number; readonly limit: number },
) => {
switch (source.operation) {
case "procedure_address":
return {
parent_operation: source.operation,
total: 1,
items: {
type: "addresses" as const,
rows: selectedRows([source.result], view).map((row) => ({
source_index: row.source_index,
address: row.value,
})),
},
};
case "procedure_callees":
case "procedure_callers":
return {
parent_operation: source.operation,
total: source.result.length,
items: {
type: "addresses" as const,
rows: selectedRows(source.result, view).map((row) => ({
source_index: row.source_index,
address: row.value,
})),
},
};
case "resolve_containing_procedure":
return {
parent_operation: source.operation,
total: 1,
items: {
type: "resolutions" as const,
rows: selectedRows([source.result], view).map((row) => ({
source_index: row.source_index,
record: row.value,
})),
},
};
case "get_call_graph":
return {
parent_operation: source.operation,
...projectNativeGraphRows(source.result, view),
};
}
};
const contextRows = (
source: NativeSourceContext,
view: Extract<AnalysisViewRequest, { kind: "native-context" }>,
) => {
switch (view.facet) {
case "overview": {
const {
analysis_profile,
limitations: _limitations,
result_limitations: _result,
...overview
} = source;
return {
facet: view.facet,
view: { ...view, facet: view.facet },
total: 1,
items: {
type: "overview" as const,
rows: selectedRows(
[
{
...overview,
analysis_profile_digest: analysis_profile?.digest ?? null,
},
],
view,
).map((row) => ({
source_index: row.source_index,
record: row.value,
})),
},
};
}
case "analysis-profile":
return {
facet: view.facet,
view: { ...view, facet: view.facet },
total: 1,
items: {
type: "analysis-profile" as const,
rows: selectedRows([source.analysis_profile], view).map((row) => ({
source_index: row.source_index,
record: row.value,
})),
},
};
case "limitations":
return {
facet: view.facet,
view: { ...view, facet: view.facet },
total: source.limitations.length,
items: {
type: "limitations" as const,
rows: selectedRows(source.limitations, view).map((row) => ({
source_index: row.source_index,
text: row.value,
})),
},
};
case "result-limitations":
return {
facet: view.facet,
view: { ...view, facet: view.facet },
total: source.result_limitations?.length ?? null,
items: {
type: "result-limitations" as const,
rows:
source.result_limitations === null
? null
: selectedRows(source.result_limitations, view).map((row) => ({
source_index: row.source_index,
text: row.value,
})),
},
};
}
};
/** Select recorded native facts without repeating provider profiles or complete caveat inventories. */
export const projectNativeObservationView = (
parent: AnalysisViewParent,
view: AnalysisViewRequest,
): Result<UnsignedAnalysisView, AnalysisError> => {
if (
view.kind !== "summary" &&
view.kind !== "native-observation" &&
view.kind !== "native-context"
)
return err(
analysisViewInputError([
{
path: ["view"],
reason: "invalid_value",
message:
"Use summary, native-observation or native-context with this source.",
},
]),
);
const source = readSource(parent);
if (!source.ok) return source;
const context = readContext(parent, source.value);
if (!context.ok) return context;
const sourceContext = {
materialization: "reference" as const,
digest: digestCanonicalValue(
{
...context.value,
subject: evidenceSubjectIdentity(context.value.subject),
},
"Native source context",
),
evidence_limitation_count: context.value.limitations.length,
result_limitation_count: context.value.result_limitations?.length ?? null,
};
const common = {
parent_evidence_id: parent.evidenceId,
parent_digest: parent.evidenceId.slice(3),
artifact: parent.artifact ?? null,
source_provider: context.value.provider,
source_analysis_profile_digest:
context.value.analysis_profile?.digest ?? null,
source_parameters: jsonValueSchema.parse(parent.parameters ?? {}),
source_context: sourceContext,
limitations: ["Source caveats require native-context."],
unknowns: [
"Interpretation beyond recorded rows is unknown until source caveats are read.",
],
};
if (view.kind === "native-context") {
const { total, ...page } = contextRows(context.value, view);
return ok({
...common,
kind: "native-context",
...page,
parent_operation: source.value.operation,
coverage:
total === null
? completeWithinViewCoverage(0, 0)
: pageViewCoverage(view.offset, page.items.rows?.length ?? 0, total),
unknowns:
total === null
? [
"Result limitations are not applicable to this primitive observation; null does not report an examined empty list.",
]
: common.unknowns,
});
}
const selection = view.kind === "summary" ? { offset: 0, limit: 64 } : view;
const page = observationRows(source.value, selection);
const { total, ...payload } = page;
const observation = {
...common,
...payload,
summary: { observations: total },
coverage: pageViewCoverage(selection.offset, page.items.rows.length, total),
};
return view.kind === "summary"
? ok({ ...observation, kind: "summary", view })
: ok({ ...observation, kind: "native-observation", view });
};
+11 -8
View File
@@ -269,18 +269,21 @@ export interface EvidenceObservation extends EvidenceObservationInput {
type EvidenceWithoutId = Omit<EvidenceSchemaValue, "evidence_id">;
/** Target identity shared by semantic commitments, excluding caller-selected display paths. */
export const evidenceSubjectIdentity = (subject: Evidence["subject"]) =>
subject === null
? null
: {
digest: subject.digest,
format: subject.format,
architecture: subject.architecture,
};
/** Content committed by an Evidence ID, excluding local request/display metadata. */
export const evidenceSemanticContent = (
evidence: EvidenceWithoutId,
): Record<string, JsonValue> => ({
subject:
evidence.subject === null
? null
: {
digest: evidence.subject.digest,
format: evidence.subject.format,
architecture: evidence.subject.architecture,
},
subject: evidenceSubjectIdentity(evidence.subject),
provider: evidence.provider,
analysis_profile: evidence.analysis_profile,
predicate_type: evidence.predicate_type,
+26
View File
@@ -0,0 +1,26 @@
import { z } from "zod";
import { analysisErrorProjectionSchema } from "../../contracts/errorSchemas.js";
import { relationshipCoverageSchema } from "../relationshipCoverage.js";
/** One recorded traversal outcome; source levels and array positions retain identity. */
export const nativeCallGraphNodeSchema = z.discriminatedUnion("status", [
z.object({
address: z.string(),
status: z.literal("ok"),
calls: z.array(z.string()),
relationship_coverage: relationshipCoverageSchema,
limitations: z.array(z.string()).optional(),
}),
z.object({
address: z.string(),
status: z.literal("error"),
error: analysisErrorProjectionSchema,
}),
]);
/** Provider-independent result owned by the shared call-graph workflow. */
export const nativeCallGraphSchema = z.strictObject({
graph: z.record(z.string(), z.array(nativeCallGraphNodeSchema)),
limitations: z.array(z.string()),
});
export type NativeCallGraph = z.infer<typeof nativeCallGraphSchema>;
+36
View File
@@ -0,0 +1,36 @@
import { z } from "zod";
import { containingProcedureResolutionSchema } from "../hopperValues.js";
import { nativeCallGraphSchema } from "./nativeCallGraph.js";
export const NATIVE_PRIMITIVE_OBSERVATION_OPERATIONS = [
"procedure_address",
"procedure_callees",
"procedure_callers",
"resolve_containing_procedure",
] as const;
export const NATIVE_OBSERVATION_OPERATIONS = [
...NATIVE_PRIMITIVE_OBSERVATION_OPERATIONS,
"get_call_graph",
] as const;
export type NativeObservationOperation =
(typeof NATIVE_OBSERVATION_OPERATIONS)[number];
export const nativeAddressListSchema = z.array(z.string());
export const nativeObservationSourceSchemas = {
procedure_address: z.string(),
procedure_callees: nativeAddressListSchema,
procedure_callers: nativeAddressListSchema,
resolve_containing_procedure: containingProcedureResolutionSchema,
get_call_graph: nativeCallGraphSchema,
};
export type NativeObservation = {
[Operation in NativeObservationOperation]: {
operation: Operation;
result: z.output<(typeof nativeObservationSourceSchemas)[Operation]>;
};
}[NativeObservationOperation];
/** One operation family shared by view projection and response-budget recovery. */
export const isNativeObservationOperation = (
operation: string,
): operation is NativeObservationOperation =>
NATIVE_OBSERVATION_OPERATIONS.some((supported) => supported === operation);
+6 -6
View File
@@ -3,7 +3,7 @@ import { z } from "zod";
import { fixedTupleSchema } from "./fixedTupleSchema.js";
const diagnostics = z.array(z.string().min(1)).min(1).exactOptional();
const completeCoverage = z.strictObject({
export const completeRelationshipCoverageSchema = z.strictObject({
status: z.literal("complete"),
diagnostics,
});
@@ -12,13 +12,13 @@ const limitedDirections = z.union([
fixedTupleSchema([z.literal("callees")]),
fixedTupleSchema([z.literal("callers"), z.literal("callees")]),
]);
const partialCoverage = z.strictObject({
export const partialRelationshipCoverageSchema = z.strictObject({
status: z.literal("partial"),
limitations: z.array(z.string().min(1)).min(1),
limited_directions: limitedDirections.exactOptional(),
diagnostics,
});
const unknownCoverage = z.strictObject({
export const unknownRelationshipCoverageSchema = z.strictObject({
status: z.literal("unknown"),
reason: z.string().min(1),
limited_directions: limitedDirections.exactOptional(),
@@ -27,9 +27,9 @@ const unknownCoverage = z.strictObject({
/** Explicit coverage state for a provider's observed caller/callee set. */
export const relationshipCoverageSchema = z.discriminatedUnion("status", [
completeCoverage,
partialCoverage,
unknownCoverage,
completeRelationshipCoverageSchema,
partialRelationshipCoverageSchema,
unknownRelationshipCoverageSchema,
]);
export type RelationshipCoverage = z.infer<typeof relationshipCoverageSchema>;
+25 -3
View File
@@ -25,6 +25,7 @@ import { toolContract } from "../contracts/toolContracts.js";
import type { ToolContract } from "../contracts/toolContractTypes.js";
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import { ok } from "../domain/result.js";
import type { JsonValue } from "../domain/jsonValue.js";
import type { Logger } from "pino";
import { mcpProgressReporter } from "./mcpProgress.js";
@@ -146,10 +147,11 @@ export const registerEnhancedTools = (
server.registerTool(
callGraph.name,
toolRegistrationOptions(callGraph),
(input, context) =>
({ detail, ...input }, context) =>
executeEnhancedTool(registration, callGraph, {
validatedCall: { name: "get_call_graph", input },
context,
detail,
}),
);
server.registerTool(
@@ -233,10 +235,19 @@ const executeEnhancedTool = async (
},
contract: ToolContract,
request: {
readonly detail?: "complete" | "summary";
readonly validatedCall: ValidatedEnhancedCall;
readonly context: ServerContext;
},
): Promise<CallToolResult> => {
if (request.detail === "summary") {
const ready = requireAnalysisViewRetention(request.validatedCall.name, {
recordEvidence: registration.recordEvidence,
evidenceById: registration.evidenceById,
});
if (!ready.ok)
return registration.delivery.toCallToolResult(ready, contract);
}
const admitted = await registration.withAdmittedAnalysis(
request.validatedCall.name,
request.context.mcpReq.signal,
@@ -307,10 +318,21 @@ const executeEnhancedTool = async (
});
if (!unknowns.ok)
return registration.delivery.toCallToolResult(unknowns, contract);
const delivered =
request.detail === "summary"
? summarizeRetainedAnalysis(evidence, {
recordEvidence: registration.recordEvidence,
evidenceById: registration.evidenceById,
})
: ok(evidence);
if (!delivered.ok)
return registration.delivery.toCallToolResult(delivered, contract);
return registration.delivery.toEvidenceToolResult(
evidence,
delivered.value,
contract,
recorded,
request.detail === "summary"
? registration.recordEvidence?.(delivered.value)
: recorded,
);
}
return registration.delivery.toCallToolResult(result, contract);
+2
View File
@@ -1,3 +1,4 @@
import { NATIVE_PRIMITIVE_OBSERVATION_OPERATIONS } from "../domain/native/nativeObservation.js";
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
import { OFFICIAL_TOOL_CONTRACTS } from "../contracts/officialToolContracts.js";
import { NATIVE_INVENTORY_OPERATIONS } from "../domain/native/nativeInventory.js";
@@ -10,6 +11,7 @@ import {
const SUMMARY_OPERATIONS: ReadonlySet<string> = new Set([
...NATIVE_INVENTORY_OPERATIONS,
...NATIVE_PRIMITIVE_OBSERVATION_OPERATIONS,
"inspect_native_load_image",
]);
const withoutDetail = withoutDeliveryDetail(SUMMARY_OPERATIONS);
+4 -1
View File
@@ -1,3 +1,4 @@
import { isNativeObservationOperation } from "../domain/native/nativeObservation.js";
import { isNativeInventoryOperation } from "../domain/native/nativeInventory.js";
import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from "@modelcontextprotocol/server";
import { ToolResultDelivery } from "./ToolResultDelivery.js";
@@ -24,7 +25,9 @@ const retainedEvidenceContinuation = (
? { kind: "native", facet: "pseudocode", offset: 0 }
: isNativeInventoryOperation(evidence.operation)
? { kind: "native-inventory", offset: 0, limit: 64 }
: undefined;
: isNativeObservationOperation(evidence.operation)
? { kind: "native-observation", offset: 0, limit: 64 }
: undefined;
if (view === undefined) return {};
const input = inspectAnalysisViewInputSchema.safeParse({
source: { kind: "retained-evidence", evidence_id: evidence.evidence_id },
@@ -288,10 +288,18 @@ it("refuses summary before provider work when no retaining session is configured
InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
for (const name of ["list_procedures", "inspect_native_load_image"]) {
for (const [name, parameters] of [
["list_procedures", {}],
["inspect_native_load_image", {}],
["procedure_address", { procedure: "fixture_main" }],
["procedure_callees", { procedure: "fixture_main" }],
["procedure_callers", { procedure: "fixture_main" }],
["resolve_containing_procedure", { address: "0x401001" }],
["get_call_graph", { address: "fixture_main" }],
] as const) {
const reply = await client.callTool({
name,
arguments: { detail: "summary" },
arguments: { ...parameters, detail: "summary" },
});
expect(reply.isError).toBe(true);
expect(parseMcpToolError(reply).error.code).toBe("capability_unavailable");
@@ -0,0 +1,214 @@
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { createServer } from "../../../src/server/createServer.js";
import { createToolResultDelivery } from "../../../src/server/toolResult.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { ok } from "../../../src/domain/result.js";
import { z } from "zod";
import { afterAll, expect, it } from "vitest";
import { parseEvidence } from "../../../src/domain/evidence.js";
import {
analysisViewResultSchema,
inspectAnalysisViewInputSchema,
} from "../../../src/domain/analysisView/analysisView.js";
import { NATIVE_PRIMITIVE_OBSERVATION_OPERATIONS } from "../../../src/domain/native/nativeObservation.js";
import { connectGhidraMcp } from "./ghidraMcpHarness.js";
import { createMcpSchemaValidation } from "../../fixtures/mcpSchemaValidation.js";
import { advertisedMcpToolSchemas } from "../../fixtures/advertisedMcpTool.js";
const validation = createMcpSchemaValidation();
afterAll(() => validation.clear());
it("delivers native primitive and graph summaries with exact retained parents and SDK-validated context pages", async () => {
const harness = await connectGhidraMcp("native-observation-views");
try {
const definitions = new Map(
(await harness.mcp.listTools()).tools.map((tool) => [tool.name, tool]),
);
const call = async (name: string, input: Record<string, unknown>) => {
const tool = definitions.get(name);
if (!tool?.outputSchema) throw new Error("Missing named contract");
const result = await harness.mcp.callTool(
{ name, arguments: input },
{ toolDefinition: tool },
);
expect(result.isError, JSON.stringify(result.content)).not.toBe(true);
expect(
validation.assertions.validate(
tool.outputSchema,
result.structuredContent,
),
JSON.stringify(validation.assertions.errors),
).toBe(true);
return parseEvidence(result.structuredContent);
};
const viewSchemas = await advertisedMcpToolSchemas(
harness.mcp,
"inspect_analysis_view",
validation.assertions,
);
for (const operation of [
...NATIVE_PRIMITIVE_OBSERVATION_OPERATIONS,
"get_call_graph",
] as const) {
const input =
operation === "get_call_graph"
? { address: "fixture_main" }
: operation === "resolve_containing_procedure"
? { address: "0x401001" }
: { procedure: "fixture_main" };
const complete = await call(operation, input);
const summary = await call(operation, { ...input, detail: "summary" });
expect(complete.operation).toBe(operation);
expect(complete.parameters).not.toHaveProperty("detail");
expect(summary.normalized_result).toMatchObject({
kind: "summary",
parent_operation: operation,
parent_evidence_id: complete.evidence_id,
source_parameters: complete.parameters,
source_provider: complete.provider,
});
expect(harness.session.evidenceById(complete.evidence_id)).toEqual(
complete,
);
const calls = harness.calls.length;
const source = {
kind: "retained-evidence",
evidence_id: complete.evidence_id,
};
const page = await call("inspect_analysis_view", {
source,
view: { kind: "native-observation", offset: 0, limit: 1 },
});
const parsed = analysisViewResultSchema.parse(page.normalized_result);
expect(parsed).toMatchObject({
kind: "native-observation",
parent_evidence_id: complete.evidence_id,
});
for (const facet of [
"overview",
"analysis-profile",
"limitations",
"result-limitations",
]) {
const context = await call("inspect_analysis_view", {
source,
view: { kind: "native-context", facet, offset: 0, limit: 1 },
});
expect(context.relationship_coverage).toEqual(
complete.relationship_coverage,
);
expect(context.normalized_result).toMatchObject({
kind: "native-context",
facet,
parent_evidence_id: complete.evidence_id,
});
}
expect(harness.calls).toHaveLength(calls);
const invalid = {
source,
view: { kind: "native-context", facet: "unknown" },
};
expect(inspectAnalysisViewInputSchema.safeParse(invalid).success).toBe(
false,
);
const rejected = await harness.mcp.callTool(
{ name: "inspect_analysis_view", arguments: invalid },
{ toolDefinition: viewSchemas.toolDefinition },
);
expect(rejected.isError).toBe(true);
}
} finally {
await harness.close();
}
}, 90_000);
it("follows native overflow continuations through MCP without repeating provider work", async () => {
let reads = 0;
const provider = { id: "fixture", name: "Fixture", version: "1" };
const session = createTestBinarySession(() => ({
execute: async (operation) => {
reads++;
return ok(
createAnalysisExecution(
operation === "procedure_address"
? "0x1000"
: operation === "resolve_containing_procedure"
? {
query_address: "0x9999",
found: false,
procedure: null,
reason: "not_in_procedure",
}
: [],
provider,
{
limitations: ["Capacity fixture ".repeat(1500)],
relationshipCoverage: {
status: "partial",
limitations: ["Indirect edges unknown"],
},
},
),
);
},
close: async () => ok(null),
}));
// Exercise the real delivery/SDK path with a small injected response budget.
const server = createServer(
{ kind: "session", session },
{ delivery: createToolResultDelivery(32 * 1024) },
);
const client = new Client({
name: "native-observation-capacity",
version: "1",
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const opened = await client.callTool({
name: "open_binary",
arguments: { path: process.execPath },
});
expect(opened.isError).not.toBe(true);
for (const name of [
...NATIVE_PRIMITIVE_OBSERVATION_OPERATIONS,
"get_call_graph",
] as const) {
const input =
name === "get_call_graph" || name === "resolve_containing_procedure"
? { address: "0x1000" }
: { procedure: "fixture_main" };
const complete = await client.callTool({ name, arguments: input });
const error = parseMcpToolError(complete).error;
expect(error.code).toBe("resource_constraint");
const continuation = z
.object({
reported_limits: z.object({
continuation: z.object({
name: z.literal("inspect_analysis_view"),
arguments: inspectAnalysisViewInputSchema,
}),
}),
})
.parse(error.details).reported_limits.continuation;
expect(continuation.arguments.view.kind).toBe("native-observation");
const before = reads;
const page = await client.callTool(continuation);
expect(page.isError, JSON.stringify(page.content)).not.toBe(true);
const evidence = parseEvidence(page.structuredContent);
expect(evidence.normalized_result).toMatchObject({
kind: "native-observation",
parent_operation: name,
});
expect(reads).toBe(before);
}
} finally {
await Promise.all([client.close(), server.close()]);
const closed = await session.close();
if (!closed.ok) throw closed.error;
}
}, 90_000);