fix(mcp): return complete inline Evidence records (#585)
Return complete canonical Evidence records inline, including normalized_result, and preserve direct comparison and retained bundle identity. Verified full deterministic gate, real Linux Ghidra CLI/MCP and independently installed artifact, generic package acceptance, private local samples, and targeted updates after syncing main. Closes #551 Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)
This commit is contained in:
parent
205a977777
commit
ea9c3dab6b
|
|
@ -55,8 +55,13 @@ shutdown rules still apply; REA never kills a process it cannot prove it owns.
|
|||
|
||||
## Tool results
|
||||
|
||||
Evidence-producing tools return their result and Evidence context inline. Read
|
||||
that result directly. Use `get_evidence_bundle` when the task needs broader
|
||||
Evidence-producing tools return `{ result, evidence_id, evidence }` in both
|
||||
text and structured content. `evidence` is the complete canonical Evidence
|
||||
record, including `normalized_result`, which equals `result`. The same record
|
||||
is retained in the session bundle. Read `result` directly, or pass `evidence`
|
||||
to a compatible comparison tool: `analyze_function` Evidence can be passed
|
||||
directly to `compare_functions`, and `inspect_artifact` Evidence to
|
||||
`compare_artifacts`. Use `get_evidence_bundle` when the task needs broader
|
||||
retained session history or an explicit bundle for transfer.
|
||||
|
||||
## Aggregate native context
|
||||
|
|
|
|||
|
|
@ -545,9 +545,9 @@
|
|||
"mcp_prompts": 6
|
||||
},
|
||||
"digests": {
|
||||
"tools_sha256": "d898fbdf87a2684a0d7b7cffe0a5ae0b29c7f09651be912b152783738c997373",
|
||||
"tools_sha256": "dc481fd931e612386c83cea7afe609ff4b1ed57b217f4ed877e5210d7ff3da16",
|
||||
"prompts_sha256": "4762db348821fec787a5b8c6423f2c48a71f6f0ec2afee97b16ad69030ec504b",
|
||||
"combined_sha256": "3efc2688f505dd5fee02c730efddb4fbeba79db0ab34c40494a5a1fc55be308b",
|
||||
"combined_sha256": "9a38caecda4d0d5bae02db49909e2ae3b2560b8bcf0fac8885345097d856f931",
|
||||
"providers_sha256": "2ae8b85b20be338739e500549d275e78e875aaa10235d2ca283f0ade7787da90"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"manifest_id": "ecm_9218fd2c70d82c981443b36551dfb24e045dcc9811db71a540b1cf6a37651767",
|
||||
"manifest_id": "ecm_4d23d0a47c714a48078502042f6f7f902e8c591375e5f4c06dbc92d0263a65db",
|
||||
"verifier": {
|
||||
"id": "rea-managed-conformance",
|
||||
"version": "1"
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
"skill_digests": [
|
||||
{
|
||||
"skill_id": "reverse-engineer-anything",
|
||||
"sha256": "316944ca527c869a3d1b1d1af58b79386d057d8cdd47eb163805b665492f6a55"
|
||||
"sha256": "dcfaef23bdaf8bc6defab4a2f65d419f53f35c968b83f8abc665dce49453df23"
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { parseEvidence } from "../../dist/domain/evidence.js";
|
|||
import { analysisProfileSchema } from "../../dist/domain/analysisProfile.js";
|
||||
import { nativeValueTraceSchema } from "../../dist/domain/nativeValueTrace.js";
|
||||
|
||||
const entrypoint = fileURLToPath(new URL("../rea.mjs", import.meta.url));
|
||||
const defaultEntrypoint = fileURLToPath(new URL("../rea.mjs", import.meta.url));
|
||||
const environment = () => ({
|
||||
PATH: process.env.PATH ?? "/usr/bin:/bin",
|
||||
REA_LOG_LEVEL: "silent",
|
||||
|
|
@ -67,7 +67,12 @@ const verifyGraph = (input, target, procedure, globalAddress) => {
|
|||
};
|
||||
|
||||
/** Verify real Ghidra dependency composition through production CLI and stdio MCP. */
|
||||
export async function verifyNativeValueE2e(target, procedure, globalAddress) {
|
||||
export async function verifyNativeValueE2e(
|
||||
target,
|
||||
procedure,
|
||||
globalAddress,
|
||||
{ entrypoint = defaultEntrypoint } = {},
|
||||
) {
|
||||
const { stdout } = await promisify(execFile)(
|
||||
process.execPath,
|
||||
[
|
||||
|
|
@ -142,10 +147,7 @@ export async function verifyNativeValueE2e(target, procedure, globalAddress) {
|
|||
{ timeout: 180000 },
|
||||
);
|
||||
assert.notEqual(result.isError, true, JSON.stringify(result));
|
||||
const mcpEvidence = parseEvidence({
|
||||
...result.structuredContent?.evidence,
|
||||
normalized_result: result.structuredContent?.result,
|
||||
});
|
||||
const mcpEvidence = parseEvidence(result.structuredContent?.evidence);
|
||||
assert.equal(mcpEvidence.operation, "trace_native_values");
|
||||
assert.equal(mcpEvidence.provider.id, "rea-workflow");
|
||||
assert.deepEqual(mcpEvidence.analysis_profile, workflowProfile);
|
||||
|
|
@ -157,6 +159,66 @@ export async function verifyNativeValueE2e(target, procedure, globalAddress) {
|
|||
);
|
||||
assert.deepEqual(mcpEvidence.normalized_result, mcpGraph);
|
||||
assert.deepEqual(mcpGraph, cliGraph);
|
||||
const analyzed = await client.callTool(
|
||||
{ name: "analyze_function", arguments: { procedure } },
|
||||
{ timeout: 180000 },
|
||||
);
|
||||
assert.notEqual(analyzed.isError, true, JSON.stringify(analyzed));
|
||||
const functionEvidence = parseEvidence(
|
||||
analyzed.structuredContent?.evidence,
|
||||
);
|
||||
assert.equal(functionEvidence.operation, "analyze_function");
|
||||
assert.equal(functionEvidence.provider.id, "rea-workflow");
|
||||
const functionProfile = analysisProfileSchema.parse(
|
||||
functionEvidence.analysis_profile,
|
||||
);
|
||||
assert.deepEqual(
|
||||
functionProfile.parameters.upstream_analysis_profile,
|
||||
upstreamProfile,
|
||||
);
|
||||
assert.deepEqual(
|
||||
functionEvidence.normalized_result,
|
||||
analyzed.structuredContent?.result,
|
||||
);
|
||||
const comparison = await client.callTool(
|
||||
{
|
||||
name: "compare_functions",
|
||||
arguments: {
|
||||
left: analyzed.structuredContent.evidence,
|
||||
right: analyzed.structuredContent.evidence,
|
||||
},
|
||||
},
|
||||
{ timeout: 180000 },
|
||||
);
|
||||
assert.notEqual(comparison.isError, true, JSON.stringify(comparison));
|
||||
const comparisonEvidence = parseEvidence(
|
||||
comparison.structuredContent?.evidence,
|
||||
);
|
||||
assert.equal(comparisonEvidence.normalized_result.status, "unchanged");
|
||||
assert.deepEqual(
|
||||
comparisonEvidence.normalized_result,
|
||||
comparison.structuredContent?.result,
|
||||
);
|
||||
assert.ok(
|
||||
comparisonEvidence.evidence_links.includes(functionEvidence.evidence_id),
|
||||
);
|
||||
const bundle = await client.callTool({
|
||||
name: "get_evidence_bundle",
|
||||
arguments: {},
|
||||
});
|
||||
assert.notEqual(bundle.isError, true, JSON.stringify(bundle));
|
||||
for (const evidence of [
|
||||
mcpEvidence,
|
||||
functionEvidence,
|
||||
comparisonEvidence,
|
||||
]) {
|
||||
assert.deepEqual(
|
||||
bundle.structuredContent?.result?.records.find(
|
||||
(record) => record.evidence_id === evidence.evidence_id,
|
||||
),
|
||||
evidence,
|
||||
);
|
||||
}
|
||||
const closed = await client.callTool({
|
||||
name: "close_binary",
|
||||
arguments: {},
|
||||
|
|
@ -175,6 +237,8 @@ export async function verifyNativeValueE2e(target, procedure, globalAddress) {
|
|||
mocked: false,
|
||||
cli: true,
|
||||
stdio_mcp: true,
|
||||
inline_evidence: true,
|
||||
direct_function_comparison: true,
|
||||
nodes: cliGraph.total_nodes,
|
||||
edges: cliGraph.total_edges,
|
||||
decompilations: cliGraph.decompilations,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { join } from "node:path";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
|
@ -5,6 +6,7 @@ import { promisify } from "node:util";
|
|||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { MCP_STARTUP_POLICY } from "../dist/mcpStartupPolicy.js";
|
||||
import { parseEvidence } from "../dist/domain/evidence.js";
|
||||
import * as prompts from "./verify-package-prompts.mjs";
|
||||
import { json, verifyCompleteToolCatalog } from "./lib/verify-package-core.mjs";
|
||||
|
||||
|
|
@ -155,8 +157,77 @@ const verifyMcpEvidenceBundle = async (client, mcpOptions, evidenceRoot) => {
|
|||
throw new Error("packaged MCP evidence import failed");
|
||||
};
|
||||
|
||||
/** Connect to the packaged MCP server and exercise the target-free catalog. */
|
||||
export async function verifyPackageMcp({ cli, environment, evidenceRoot }) {
|
||||
const verifyMcpInlineArtifactEvidence = async (
|
||||
client,
|
||||
mcpOptions,
|
||||
artifactArchive,
|
||||
) => {
|
||||
const opened = await client.callTool(
|
||||
{ name: "open_binary", arguments: { path: artifactArchive } },
|
||||
mcpOptions,
|
||||
);
|
||||
assert.notEqual(opened.isError, true, JSON.stringify(opened));
|
||||
try {
|
||||
const inspected = await client.callTool(
|
||||
{ name: "inspect_artifact", arguments: {} },
|
||||
mcpOptions,
|
||||
);
|
||||
assert.notEqual(inspected.isError, true, JSON.stringify(inspected));
|
||||
const source = parseEvidence(inspected.structuredContent?.evidence);
|
||||
assert.deepEqual(
|
||||
source.normalized_result,
|
||||
inspected.structuredContent?.result,
|
||||
);
|
||||
assert.deepEqual(
|
||||
json(prompts.mcpText(inspected)),
|
||||
inspected.structuredContent,
|
||||
);
|
||||
const compared = await client.callTool(
|
||||
{
|
||||
name: "compare_artifacts",
|
||||
arguments: {
|
||||
left: inspected.structuredContent.evidence,
|
||||
right: inspected.structuredContent.evidence,
|
||||
},
|
||||
},
|
||||
mcpOptions,
|
||||
);
|
||||
assert.notEqual(compared.isError, true, JSON.stringify(compared));
|
||||
const comparison = parseEvidence(compared.structuredContent?.evidence);
|
||||
assert.deepEqual(
|
||||
comparison.normalized_result,
|
||||
compared.structuredContent?.result,
|
||||
);
|
||||
assert.ok(comparison.evidence_links.includes(source.evidence_id));
|
||||
const bundle = await client.callTool(
|
||||
{ name: "get_evidence_bundle", arguments: {} },
|
||||
mcpOptions,
|
||||
);
|
||||
assert.notEqual(bundle.isError, true, JSON.stringify(bundle));
|
||||
for (const evidence of [source, comparison]) {
|
||||
assert.deepEqual(
|
||||
bundle.structuredContent?.result?.records.find(
|
||||
(record) => record.evidence_id === evidence.evidence_id,
|
||||
),
|
||||
evidence,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
const closed = await client.callTool(
|
||||
{ name: "close_binary", arguments: {} },
|
||||
mcpOptions,
|
||||
);
|
||||
assert.notEqual(closed.isError, true, JSON.stringify(closed));
|
||||
}
|
||||
};
|
||||
|
||||
/** Connect to the packaged MCP server and verify catalog and Evidence composition. */
|
||||
export async function verifyPackageMcp({
|
||||
cli,
|
||||
environment,
|
||||
evidenceRoot,
|
||||
artifactArchive,
|
||||
}) {
|
||||
const diagnosed = json(
|
||||
(
|
||||
await execute(cli, ["mcp", "doctor", "--json"], {
|
||||
|
|
@ -191,6 +262,7 @@ export async function verifyPackageMcp({ cli, environment, evidenceRoot }) {
|
|||
await verifyMcpTargetFree(client, mcpOptions);
|
||||
await verifyMcpUnknownProvider(client, mcpOptions);
|
||||
await verifyMcpBinaryLifecycle(client, mcpOptions);
|
||||
await verifyMcpInlineArtifactEvidence(client, mcpOptions, artifactArchive);
|
||||
await verifyMcpEvidenceBundle(client, mcpOptions, evidenceRoot);
|
||||
} catch (cause) {
|
||||
throw new Error(`packaged MCP smoke failed: ${mcpStderr}`, { cause });
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ try {
|
|||
cli,
|
||||
environment: environmentData.environment,
|
||||
});
|
||||
await verifyPackageArtifactAndElectron({
|
||||
const { artifactArchive } = await verifyPackageArtifactAndElectron({
|
||||
cli,
|
||||
workspace,
|
||||
environment: environmentData.environment,
|
||||
|
|
@ -131,6 +131,7 @@ try {
|
|||
cli,
|
||||
environment: environmentData.environment,
|
||||
evidenceRoot,
|
||||
artifactArchive,
|
||||
});
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ verifier_run: await completeVerifierRun(verifierRun), cli: true, analysisCli: true, artifactCli: true, managedCli: true, managedReconstructionCli: true, managedNativeVerificationCli: true, managedApplicationGraphCli: true, evidenceCli: true, incurMcpCommand: PRODUCT_IDENTITY.mcpCommand, lifecycleScriptsRequired: false, doctor: "platform-appropriate", setup: supportedSetupHost ? "planned-then-idempotent" : "unsupported-host-rejected", setupPlanReadOnly: supportedSetupHost, existingHopperPreserved: supportedSetupHost, clients: supportedSetupHost ? 4 : 0, backupReadback: supportedSetupHost, failureRecovery: supportedSetupHost, configSymlinkLifecycle: supportedSetupHost, skill: supportedSetupHost, skillReferences: supportedSetupHost, mcpTools: TOOL_CONTRACTS.length, mcpPrompts: prompts.names.length, promptCompletion: true, promptCompletionLifecycle: true, evidenceMcp: true, targetFree: true, targetLifecycle: true, boundedRegexBridge: true, update, mcpStartup, mcpModuleLoading })}\n`,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ description: Reverse engineer native, managed, Electron/JavaScript, packaged, an
|
|||
metadata:
|
||||
version: "23"
|
||||
tool_count: 116
|
||||
catalog_digest: "3efc2688f505dd5fee02c730efddb4fbeba79db0ab34c40494a5a1fc55be308b"
|
||||
catalog_digest: "9a38caecda4d0d5bae02db49909e2ae3b2560b8bcf0fac8885345097d856f931"
|
||||
---
|
||||
|
||||
# REA
|
||||
|
|
|
|||
|
|
@ -18,9 +18,7 @@ import { analysisErrorProjectionSchema } from "./errorSchemas.js";
|
|||
import { prefixedDigestSchema } from "./../domain/digests.js";
|
||||
|
||||
/** Inline result with its complete Evidence record. */
|
||||
export const inlineEvidenceRecordSchema = evidenceEnvelopeSchema.omit({
|
||||
normalized_result: true,
|
||||
});
|
||||
export const inlineEvidenceRecordSchema = evidenceEnvelopeSchema;
|
||||
|
||||
export const evidenceResultOf = (schema: z.ZodType) =>
|
||||
z.strictObject({
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -6,7 +6,7 @@ import type { JsonValue } from "../domain/jsonValue.js";
|
|||
import type { Result } from "../domain/result.js";
|
||||
|
||||
/**
|
||||
* Translate an application result into MCP text content.
|
||||
* Serialize an application result as MCP text and structured content.
|
||||
* Error tags and safe messages remain visible while underlying causes, process
|
||||
* output, and other potentially sensitive details stay private.
|
||||
*/
|
||||
|
|
@ -36,7 +36,7 @@ const successResult = (
|
|||
contract: ToolContract,
|
||||
): CallToolResult => {
|
||||
const candidate =
|
||||
compactEvidence(value) ??
|
||||
projectEvidence(value) ??
|
||||
(contract.kind === "session" ? { result: value } : value);
|
||||
return {
|
||||
content: [
|
||||
|
|
@ -49,7 +49,7 @@ const successResult = (
|
|||
};
|
||||
};
|
||||
|
||||
const compactEvidence = (value: JsonValue): JsonValue | undefined => {
|
||||
const projectEvidence = (value: JsonValue): JsonValue | undefined => {
|
||||
if (
|
||||
typeof value !== "object" ||
|
||||
value === null ||
|
||||
|
|
@ -59,17 +59,13 @@ const compactEvidence = (value: JsonValue): JsonValue | undefined => {
|
|||
!("normalized_result" in value)
|
||||
)
|
||||
return undefined;
|
||||
const evidence = value as Record<string, JsonValue>;
|
||||
const normalizedResult = evidence.normalized_result;
|
||||
const evidenceId = evidence.evidence_id;
|
||||
const normalizedResult = value.normalized_result;
|
||||
const evidenceId = value.evidence_id;
|
||||
if (normalizedResult === undefined || typeof evidenceId !== "string")
|
||||
return undefined;
|
||||
const inlineEvidence = Object.fromEntries(
|
||||
Object.entries(evidence).filter(([key]) => key !== "normalized_result"),
|
||||
);
|
||||
return {
|
||||
result: normalizedResult,
|
||||
evidence_id: evidenceId,
|
||||
evidence: inlineEvidence,
|
||||
evidence: value,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { parseEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
|
||||
import { connectGhidraMcp } from "./ghidraMcpHarness.js";
|
||||
|
||||
describe("inline MCP Evidence composition", () => {
|
||||
it("feeds returned function Evidence directly to comparison and matches the bundle", async () => {
|
||||
const harness = await connectGhidraMcp("inline-function-evidence");
|
||||
const { mcp, session } = harness;
|
||||
try {
|
||||
const response = await mcp.callTool({
|
||||
name: "analyze_function",
|
||||
arguments: { procedure: "fixture_main" },
|
||||
});
|
||||
expect(response.isError).not.toBe(true);
|
||||
const analyzed = toolContract("analyze_function").outputSchema.parse(
|
||||
response.structuredContent,
|
||||
);
|
||||
const comparison = await mcp.callTool({
|
||||
name: "compare_functions",
|
||||
arguments: { left: analyzed.evidence, right: analyzed.evidence },
|
||||
});
|
||||
expect(comparison.isError).not.toBe(true);
|
||||
const inline = parseEvidence(analyzed.evidence);
|
||||
expect(inline.normalized_result).toEqual(analyzed.result);
|
||||
expect(session.evidenceById(inline.evidence_id)).toEqual(inline);
|
||||
const compared = toolContract("compare_functions").outputSchema.parse(
|
||||
comparison.structuredContent,
|
||||
);
|
||||
const comparisonEvidence = parseEvidence(compared.evidence);
|
||||
expect(comparisonEvidence.evidence_links).toContain(inline.evidence_id);
|
||||
expect(comparisonEvidence.normalized_result).toEqual(compared.result);
|
||||
const bundle = await mcp.callTool({
|
||||
name: "get_evidence_bundle",
|
||||
arguments: {},
|
||||
});
|
||||
const exported = toolContract("get_evidence_bundle").outputSchema.parse(
|
||||
bundle.structuredContent,
|
||||
);
|
||||
const retained = parseEvidenceBundle(exported.result);
|
||||
expect(retained.records).toContainEqual(inline);
|
||||
expect(retained.records).toContainEqual(comparisonEvidence);
|
||||
} finally {
|
||||
await harness.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -6,7 +6,8 @@ import { ok } from "../../../src/domain/result.js";
|
|||
import { err } from "../../../src/domain/result.js";
|
||||
import { HopperProcessError } from "../../../src/domain/errors.js";
|
||||
import { toCallToolResult } from "../../../src/server/toolResult.js";
|
||||
import { createEvidence } from "../../../src/domain/evidence.js";
|
||||
import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import type { JsonValue } from "../../../src/domain/jsonValue.js";
|
||||
import { evidenceResultOf } from "../../../src/contracts/toolOutputSchemas.js";
|
||||
|
||||
const contract: ToolContract = {
|
||||
|
|
@ -102,6 +103,7 @@ describe("tool result projection", () => {
|
|||
result: { value: "observed" },
|
||||
evidence_id: evidence.evidence_id,
|
||||
evidence: {
|
||||
normalized_result: { value: "observed" },
|
||||
provider: { id: "fixture", name: "Fixture", version: "1" },
|
||||
operation: "fixture",
|
||||
predicate_type: "rea.analysis",
|
||||
|
|
@ -119,5 +121,40 @@ describe("tool result projection", () => {
|
|||
type: "text",
|
||||
text: expect.stringContaining('"value":"observed"'),
|
||||
});
|
||||
const parsed = evidenceContract.outputSchema.parse(
|
||||
result.structuredContent,
|
||||
);
|
||||
expect(parseEvidence(parsed.evidence)).toEqual(evidence);
|
||||
});
|
||||
|
||||
it.each<JsonValue>([null, false, 0, "", [], { nested: [false, null, 7] }])(
|
||||
"preserves a complete reusable Evidence record for JSON result %j",
|
||||
(value) => {
|
||||
const evidence = createEvidence(
|
||||
undefined,
|
||||
{ id: "fixture", name: "Fixture", version: "1" },
|
||||
{ operation: "fixture", parameters: {}, result: value },
|
||||
);
|
||||
const evidenceContract = {
|
||||
...contract,
|
||||
outputSchema: evidenceResultOf(z.json()),
|
||||
};
|
||||
const result = toCallToolResult(ok(evidence), evidenceContract);
|
||||
const parsed = evidenceContract.outputSchema.parse(
|
||||
result.structuredContent,
|
||||
);
|
||||
expect(parsed.result).toEqual(value);
|
||||
expect(parseEvidence(parsed.evidence)).toEqual(evidence);
|
||||
expect(result.content).toEqual([
|
||||
{ type: "text", text: JSON.stringify(parsed) },
|
||||
]);
|
||||
const { normalized_result: _missingResult, ...incomplete } = evidence;
|
||||
expect(
|
||||
evidenceContract.outputSchema.safeParse({
|
||||
...parsed,
|
||||
evidence: incomplete,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue