From f8e8ea35ff08ce5071acbccab188df808708b71c Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 13 Jul 2026 03:14:15 +0000 Subject: [PATCH 01/12] fix(bridge): harden bounded Hopper boundaries --- bridge/hopper_bridge.py | 22 ++++++++-- src/application/EnhancedTools.ts | 2 + src/contracts/toolOutputSchemas.ts | 12 +++--- src/domain/errors.ts | 13 +++++- src/domain/hopperValues.ts | 41 +++++++++++++++--- src/hopper/protocol.ts | 21 ++++++++- tests/bridgeTruthfulness.test.ts | 11 +++++ tests/enhancedTools.test.ts | 69 ++++++++++++++++++++++++++++++ tests/fixtures/fakeHopper.mjs | 68 ++++++++++++++++++++++++++++- tests/hopperClient.test.ts | 11 +++++ tests/hopperValues.test.ts | 24 +++++++++++ 11 files changed, 275 insertions(+), 19 deletions(-) diff --git a/bridge/hopper_bridge.py b/bridge/hopper_bridge.py index de9d3297b..46f8515c3 100644 --- a/bridge/hopper_bridge.py +++ b/bridge/hopper_bridge.py @@ -274,9 +274,15 @@ def _search_page(document, kind, params): raise ValueError("Invalid regex pattern") from error matches = lambda value: expression.search(value) is not None - matching = [item for item in _search_inventory(document, kind) if matches(item[1])] - selected = matching[offset:offset + limit] - total = len(matching) + selected = [] + total = 0 + page_end = offset + limit + for item in _search_inventory(document, kind): + if not matches(item[1]): + continue + if offset <= total < page_end: + selected.append(item) + total += 1 next_offset = offset + len(selected) has_more = next_offset < total return { @@ -654,7 +660,7 @@ def _serve_connection(connection): should_stop = request["method"] == "shutdown" response = {"id": request_id, "result": _json_safe(result)} except Exception as error: - response = {"id": request_id if isinstance(request_id, int) else 0, "error": {"code": -32000, "message": str(error)[:512]}} + response = {"id": request_id if isinstance(request_id, int) else 0, "error": {"code": -32000, "message": str(error)[:512], "type": _diagnostic_type(error)}} file.write((json.dumps(response, separators=(",", ":")) + "\n").encode("utf-8")) file.flush() if should_stop: @@ -663,6 +669,14 @@ def _serve_connection(connection): connection.close() +def _diagnostic_type(error): + if isinstance(error, PermissionError): + return "authorization" + if isinstance(error, (ValueError, TypeError, KeyError)): + return "invalid_request" + return "bridge_exception" + + def _run(): """Own a permission-restricted, single-client Unix socket for this bridge.""" if os.path.exists(REA_SOCKET): diff --git a/src/application/EnhancedTools.ts b/src/application/EnhancedTools.ts index aa9cdf949..9f65c3a99 100644 --- a/src/application/EnhancedTools.ts +++ b/src/application/EnhancedTools.ts @@ -6,6 +6,7 @@ import type { EnhancedToolName } from "../contracts/enhancedInputs.js"; import { enhancedInputSchemas } from "../contracts/enhancedInputs.js"; import { AnalysisInputError, + AnalysisCancelledError, AnalysisOutputError, type AnalysisError, } from "../domain/errors.js"; @@ -472,6 +473,7 @@ export class EnhancedTools { arguments_: Readonly>, signal?: AbortSignal, ): Promise> { + if (signal?.aborted === true) return err(new AnalysisCancelledError(name)); const execution = await this.analysis.execute( name, arguments_, diff --git a/src/contracts/toolOutputSchemas.ts b/src/contracts/toolOutputSchemas.ts index ac323bd71..a1988ef65 100644 --- a/src/contracts/toolOutputSchemas.ts +++ b/src/contracts/toolOutputSchemas.ts @@ -87,12 +87,14 @@ const providerCapability = z.object({ }), limitations: z.array(z.string()), }); +const providerIdentity = z.object({ + id: z.string(), + name: z.string(), + version: z.string().nullable(), +}); const sessionProvider = z.object({ - provider: z.object({ - id: z.string(), - name: z.string(), - version: z.string().nullable(), - }), + provider: providerIdentity, + providers: z.array(providerIdentity), capabilities: z.array(providerCapability), }); const nullableText = z.string().nullable(); diff --git a/src/domain/errors.ts b/src/domain/errors.ts index d53376e62..ed67d576f 100644 --- a/src/domain/errors.ts +++ b/src/domain/errors.ts @@ -216,6 +216,12 @@ export class HopperProtocolError extends HopperError { readonly _tag = "HopperProtocolError"; } +export type HopperDiagnosticType = + | "remote" + | "authorization" + | "invalid_request" + | "bridge_exception"; + /** Hopper's JSON-RPC endpoint returned an expected remote error response. */ export class HopperRemoteError extends HopperError { readonly _tag = "HopperRemoteError"; @@ -223,6 +229,7 @@ export class HopperRemoteError extends HopperError { constructor( readonly code: number, readonly safeMessage: string, + readonly diagnosticType: HopperDiagnosticType = "remote", ) { super(`Hopper request failed (${String(code)}): ${safeMessage}`); } @@ -322,7 +329,11 @@ const safeDetails = ( if (error instanceof HopperTimeoutError) return { timeoutMs: error.timeoutMs }; if (error instanceof HopperRemoteError) - return { code: error.code, safeMessage: error.safeMessage }; + return { + code: error.code, + safeMessage: error.safeMessage, + diagnosticType: error.diagnosticType, + }; if (error instanceof HopperProcessError) return { exitCode: error.exitCode }; return {}; }; diff --git a/src/domain/hopperValues.ts b/src/domain/hopperValues.ts index 79f551120..a1e1377c6 100644 --- a/src/domain/hopperValues.ts +++ b/src/domain/hopperValues.ts @@ -37,11 +37,31 @@ const segmentSchema = z.object({ writable: z.boolean().nullable().default(null), executable: z.boolean().nullable().default(null), }); -const addressedPageSchema = z.object({ - items: z.array(z.object({ address: z.string(), value: z.string() })), - next_offset: z.number().int().min(0).nullable(), - has_more: z.boolean(), -}); +const addressedPageSchema = z + .object({ + items: z.array(z.object({ address: z.string(), value: z.string() })), + offset: z.number().int().min(0), + limit: z.number().int().min(1), + total: z.number().int().min(0), + next_offset: z.number().int().min(0).nullable(), + has_more: z.boolean(), + }) + .superRefine((value, context) => { + if (value.has_more && value.next_offset === null) { + context.addIssue({ + code: "custom", + message: "a page with more results must provide next_offset", + path: ["next_offset"], + }); + } + if (!value.has_more && value.next_offset !== null) { + context.addIssue({ + code: "custom", + message: "a complete page must not provide next_offset", + path: ["next_offset"], + }); + } + }); const unavailableSchema = z .object({ available: z.literal(false), reason: z.string() }) .strict(); @@ -86,6 +106,17 @@ const boundedSchema = (item: T) => path: ["next_offset"], }); } + if ( + !value.truncated && + value.total !== null && + value.total !== value.returned + ) { + context.addIssue({ + code: "custom", + message: "a complete collection total must equal returned", + path: ["total"], + }); + } }); const referenceEdgeSchema = z .object({ diff --git a/src/hopper/protocol.ts b/src/hopper/protocol.ts index 3aabcfc7b..92571a42e 100644 --- a/src/hopper/protocol.ts +++ b/src/hopper/protocol.ts @@ -11,7 +11,18 @@ const responseSchema = z.union([ }), z.object({ id: z.number().int().nonnegative(), - error: z.object({ code: z.number().int(), message: z.string() }), + error: z.object({ + code: z.number().int(), + message: z.string(), + type: z + .enum([ + "remote", + "authorization", + "invalid_request", + "bridge_exception", + ]) + .default("remote"), + }), }), ]); @@ -46,5 +57,11 @@ export const responseResult = ( response: HopperResponse, ): Result => "error" in response - ? err(new HopperRemoteError(response.error.code, response.error.message)) + ? err( + new HopperRemoteError( + response.error.code, + response.error.message, + response.error.type, + ), + ) : ok(response.result); diff --git a/tests/bridgeTruthfulness.test.ts b/tests/bridgeTruthfulness.test.ts index e84318053..2a46717aa 100644 --- a/tests/bridgeTruthfulness.test.ts +++ b/tests/bridgeTruthfulness.test.ts @@ -73,6 +73,11 @@ describe("Hopper bridge truthfulness", () => { expect(bridgeSource).toContain("MAX_SEARCH_PATTERN_LENGTH = 256"); expect(bridgeSource).toContain("MAX_SEARCH_VALUE_LENGTH = 4096"); expect(bridgeSource).toContain('"value_truncated"'); + expect(bridgeSource).toContain("page_end = offset + limit"); + expect(bridgeSource).toContain("if offset <= total < page_end:"); + expect(bridgeSource).not.toContain( + "matching = [item for item in _search_inventory(document, kind)", + ); }); it("caches sorted search inventories and invalidates them after renames", () => { @@ -80,4 +85,10 @@ describe("Hopper bridge truthfulness", () => { expect(bridgeSource).toContain("tuple(sorted(values.items()"); expect(bridgeSource).toContain("_invalidate_search_inventory(document)"); }); + + it("classifies bridge exceptions without forwarding tracebacks", () => { + expect(bridgeSource).toContain("def _diagnostic_type(error):"); + expect(bridgeSource).toContain('"type": _diagnostic_type(error)'); + expect(bridgeSource).not.toContain("traceback.format_exc"); + }); }); diff --git a/tests/enhancedTools.test.ts b/tests/enhancedTools.test.ts index cf7b26103..0b441c578 100644 --- a/tests/enhancedTools.test.ts +++ b/tests/enhancedTools.test.ts @@ -3,6 +3,7 @@ import type { CallToolResult } from "@modelcontextprotocol/server"; import { afterEach, describe, expect, it } from "vitest"; import type { AnalysisOperationPort } from "../src/application/AnalysisProvider.js"; +import { EnhancedTools } from "../src/application/EnhancedTools.js"; import { ENHANCED_TOOL_CONTRACTS } from "../src/contracts/toolContracts.js"; import { observed as ok } from "./fixtures/analysisExecution.js"; import { jsonValueSchema, type JsonValue } from "../src/domain/jsonValue.js"; @@ -304,6 +305,38 @@ describe("enhanced MCP tools", () => { expect(result).toMatchObject({ count: 2 }); }); + it("does not start another page after cancellation", async () => { + const controller = new AbortController(); + let calls = 0; + const analysis: AnalysisOperationPort = { + execute: () => { + calls += 1; + controller.abort(); + return Promise.resolve( + ok({ + items: [{ address: "0x1", value: "_TtC5First" }], + offset: 0, + limit: 500, + total: 2, + next_offset: 500, + has_more: true, + }), + ); + }, + }; + + const result = await new EnhancedTools(analysis).execute( + "swift_classes", + {}, + controller.signal, + ); + + expect(calls).toBe(1); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected cancellation"); + expect(result.error._tag).toBe("AnalysisCancelledError"); + }); + it("follows name pagination for exhaustive Objective-C discovery", async () => { const offsets: number[] = []; const client = await connect({ @@ -534,4 +567,40 @@ describe("enhanced MCP tools", () => { "AnalysisOutputError", ); }); + + it("rejects complete dossier collections with inflated totals", async () => { + const malformedPort = fixturePort(); + const client = await connect({ + execute: async (name, arguments_, options) => { + const result = await malformedPort.execute(name, arguments_, options); + if (!result.ok || name !== "analyze_function") return result; + const dossier = result.value.result; + if ( + typeof dossier !== "object" || + dossier === null || + Array.isArray(dossier) + ) + return result; + return ok({ + ...dossier, + callers: { + items: [], + total: 1, + returned: 0, + truncated: false, + next_offset: null, + }, + }); + }, + }); + const result = await client.callTool({ + name: "analyze_function", + arguments: { procedure: "0x1" }, + }); + expect(result.isError).toBe(true); + const text = result.content.find((item) => item.type === "text"); + expect(text?.type === "text" ? text.text : "").toContain( + "AnalysisOutputError", + ); + }); }); diff --git a/tests/fixtures/fakeHopper.mjs b/tests/fixtures/fakeHopper.mjs index 1d39effd1..e5262d665 100644 --- a/tests/fixtures/fakeHopper.mjs +++ b/tests/fixtures/fakeHopper.mjs @@ -26,9 +26,69 @@ const enhancedFixtureResult = (method) => { has_more: false, }; case "list_strings": - return ["fixture"]; + return { + items: [{ address: "0x1000", value: "fixture" }], + offset: 0, + limit: 500, + total: 1, + next_offset: null, + has_more: false, + }; case "procedure_pseudo_code": return "return 0;"; + case "analyze_function": { + const empty = () => ({ + items: [], + total: 0, + returned: 0, + truncated: false, + next_offset: null, + }); + return { + procedure: { + address: "0x1000", + name: "fixture", + signature: null, + locals: [], + }, + pseudocode: { + text: "return 0;", + total_chars: 9, + returned_chars: 9, + truncated: false, + next_offset: null, + }, + assembly: { ...empty(), items: ["ret"], total: 1, returned: 1 }, + comments: empty(), + callers: empty(), + callees: empty(), + incoming_references: empty(), + outgoing_references: empty(), + referenced_strings: empty(), + referenced_names: empty(), + basic_blocks: { + ...empty(), + items: [{ start: "0x1000", end: "0x1001", successors: [] }], + total: 1, + returned: 1, + }, + instruction_scan: { scanned: 1, truncated: false }, + }; + } + case "search_strings": + case "search_procedures": + return { + items: [ + { address: "0x1000", value: "fixture", value_truncated: false }, + ], + offset: 0, + limit: 100, + total: 1, + next_offset: null, + has_more: false, + }; + case "xrefs": + return ["0x1000"]; default: return undefined; } @@ -79,7 +139,11 @@ const server = createServer((socket) => { } else if (request.method === "remote_error") { send({ id: request.id, - error: { code: -32001, message: "safe fake failure" }, + error: { + code: -32001, + message: "safe fake failure", + type: "bridge_exception", + }, }); } else if (request.method === "current_document") { send({ id: request.id, result: "fixture" }); diff --git a/tests/hopperClient.test.ts b/tests/hopperClient.test.ts index 4f9f1833b..5be1949d2 100644 --- a/tests/hopperClient.test.ts +++ b/tests/hopperClient.test.ts @@ -131,6 +131,17 @@ describe("HopperClient", () => { if (!result.ok) expect(result.error._tag).toBe(expectedTag); }); + it("preserves a sanitized bridge exception diagnostic", async () => { + const client = await startClient(); + const result = await client.callTool("remote_error"); + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.error).toMatchObject({ + _tag: "HopperRemoteError", + diagnosticType: "bridge_exception", + }); + }); + it("cancels and ignores late responses without corrupting the session", async () => { const client = await startClient(); const controller = new AbortController(); diff --git a/tests/hopperValues.test.ts b/tests/hopperValues.test.ts index 14a6c2262..4c319d7fd 100644 --- a/tests/hopperValues.test.ts +++ b/tests/hopperValues.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { parseDocuments, + parseAddressedPage, parseListCount, parseNames, parseProcedures, @@ -62,6 +63,29 @@ describe("Hopper boundary values", () => { }); }); + it.each([ + { + items: [], + offset: 0, + limit: 100, + total: 1, + next_offset: null, + has_more: true, + }, + { + items: [], + offset: 0, + limit: 100, + total: 0, + next_offset: 100, + has_more: false, + }, + ])("rejects contradictory page continuation metadata", (page) => { + const result = parseAddressedPage(page); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error._tag).toBe("HopperProtocolError"); + }); + it.each([ ["procedures", () => parseProcedures(["not-a-map"])], ["names", () => parseNames([{ address: 1, name: "bad" }])], From 898d6b0192126030cedd7e5c2cb76f40568cda05 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 13 Jul 2026 03:14:36 +0000 Subject: [PATCH 02/12] fix(lifecycle): validate owned Hopper process identity --- src/application/ProcessOwnership.ts | 32 +++++++++++++++++++++++++ src/hopper/BridgeLauncher.ts | 28 ++++++++++++++-------- tests/processOwnership.test.ts | 36 +++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 10 deletions(-) diff --git a/src/application/ProcessOwnership.ts b/src/application/ProcessOwnership.ts index dbd96fea0..b1de0c3c6 100644 --- a/src/application/ProcessOwnership.ts +++ b/src/application/ProcessOwnership.ts @@ -8,6 +8,10 @@ export interface OwnedProcessGroup { readonly runId: string; readonly leaderPid: number; readonly processGroupId: number; + /** Expected launcher identity, checked only while the leader exists. */ + readonly expectedCommand?: string; + /** Expected launcher parent, checked only while the leader exists. */ + readonly expectedParentPid?: number; } interface ProcessGroupMember { @@ -86,6 +90,25 @@ export const cleanupOwnedProcessGroup = async ( return { cleaned: false, reason: "process group could not be inspected" }; } if (members.length === 0) return { cleaned: true, signaled: false }; + const leader = members.find((member) => member.pid === ownership.leaderPid); + if (leader !== undefined) { + if ( + ownership.expectedParentPid !== undefined && + leader.parentPid !== ownership.expectedParentPid + ) + return { + cleaned: false, + reason: "owned launcher parent identity did not match", + }; + if ( + ownership.expectedCommand !== undefined && + !commandMatches(leader.command, ownership.expectedCommand) + ) + return { + cleaned: false, + reason: "owned launcher command identity did not match", + }; + } for (const member of members) { let environment: Readonly>; try { @@ -113,3 +136,12 @@ export const cleanupOwnedProcessGroup = async ( } return { cleaned: true, signaled: true }; }; + +const commandMatches = (actual: string, expected: string): boolean => { + const normalizedActual = actual.trim(); + const normalizedExpected = expected.trim(); + return ( + normalizedActual === normalizedExpected || + normalizedActual.startsWith(`${normalizedExpected} `) + ); +}; diff --git a/src/hopper/BridgeLauncher.ts b/src/hopper/BridgeLauncher.ts index 2f7d1f448..7ad63b796 100644 --- a/src/hopper/BridgeLauncher.ts +++ b/src/hopper/BridgeLauncher.ts @@ -141,22 +141,18 @@ export class HopperApplicationLauncher implements BridgeLauncher { { encoding: "utf8", mode: 0o600 }, ); } catch (cause: unknown) { - await cleanupOwnedProcessGroup({ - runId: session.runId, - leaderPid: pid, - processGroupId: pid, - }); + await cleanupOwnedProcessGroup( + ownedProcessGroup(session, pid, this.options.launcherPath), + ); return err(new HopperStartError({ cause })); } return ok({ process: child, ownsProcessLifetime: true, cleanup: () => - cleanupOwnedProcessGroup({ - runId: session.runId, - leaderPid: pid, - processGroupId: pid, - }), + cleanupOwnedProcessGroup( + ownedProcessGroup(session, pid, this.options.launcherPath), + ), }); } catch (cause: unknown) { return err(new HopperStartError({ cause })); @@ -164,6 +160,18 @@ export class HopperApplicationLauncher implements BridgeLauncher { } } +const ownedProcessGroup = ( + session: BridgeSession, + pid: number, + launcherPath: string, +) => ({ + runId: session.runId, + leaderPid: pid, + processGroupId: pid, + expectedCommand: launcherPath, + expectedParentPid: process.pid, +}); + const prepareHopperApplication = async ( launcherPath: string, signal?: AbortSignal, diff --git a/tests/processOwnership.test.ts b/tests/processOwnership.test.ts index 04e2abfdb..d98927a36 100644 --- a/tests/processOwnership.test.ts +++ b/tests/processOwnership.test.ts @@ -67,4 +67,40 @@ describe("owned process-group cleanup", () => { signaled: false, }); }); + + it("fails closed when the launcher command identity changes", async () => { + const { adapter, signalGroup } = host({ + 100: { REA_PROCESS_RUN_ID: "run-token" }, + }); + expect( + await cleanupOwnedProcessGroup( + { + ...ownership, + expectedCommand: "/owned/hopper", + expectedParentPid: 1, + }, + adapter, + ), + ).toEqual({ + cleaned: false, + reason: "owned launcher command identity did not match", + }); + expect(signalGroup).not.toHaveBeenCalled(); + }); + + it("fails closed when the launcher parent identity changes", async () => { + const { adapter, signalGroup } = host({ + 100: { REA_PROCESS_RUN_ID: "run-token" }, + }); + expect( + await cleanupOwnedProcessGroup( + { ...ownership, expectedCommand: "fixture", expectedParentPid: 999 }, + adapter, + ), + ).toEqual({ + cleaned: false, + reason: "owned launcher parent identity did not match", + }); + expect(signalGroup).not.toHaveBeenCalled(); + }); }); From b1912b18e1723c594a030b16acf01341f083455b Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 13 Jul 2026 03:15:01 +0000 Subject: [PATCH 03/12] feat(cli): align terminal workflows with MCP --- scripts/verify-package.mjs | 103 +++++++- src/application/BinarySession.ts | 11 +- src/application/DirectAnalysis.ts | 37 ++- src/application/EvidenceBundleCommands.ts | 36 ++- src/cli.ts | 287 +++++++++++++++------- src/cliEvidenceCommands.ts | 89 +++++++ src/cliLogging.ts | 32 +++ tests/binarySession.test.ts | 1 + tests/evidenceBundleFiles.test.ts | 16 ++ 9 files changed, 514 insertions(+), 98 deletions(-) create mode 100644 src/cliEvidenceCommands.ts create mode 100644 src/cliLogging.ts diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index a61a2600c..3d2649e71 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -122,7 +122,15 @@ try { !help.includes("inventory-artifact") || !help.includes("extract-artifact") || !help.includes("import-reference-source") || + !help.includes("compare") || !llms.includes("decompile") || + !llms.includes("function") || + !llms.includes("search") || + !llms.includes("inspect") || + !llms.includes("xrefs") || + !llms.includes("trace") || + !llms.includes("capabilities") || + !llms.includes("providers") || !llms.includes("inventory-artifact") || doctor.healthy !== expectedDoctorHealth || doctor.checks?.find(({ name }) => name === "hopper")?.ok !== true @@ -157,6 +165,86 @@ try { throw new Error( `packaged Hopper-backed analyze CLI failed: ${JSON.stringify(overview)}`, ); + const inspected = json( + await run( + cli, + [ + "inspect", + process.execPath, + "--detail", + "detailed", + "--limit", + "1", + "--json", + ], + environment, + ), + ); + if ( + inspected.operation !== "binary_overview" || + inspected.normalized_result?.detail !== "detailed" + ) + throw new Error("packaged inspect CLI failed"); + const functionResult = json( + await run( + cli, + ["function", process.execPath, "0x1000", "--json"], + environment, + ), + ); + if ( + functionResult.operation !== "analyze_function" || + functionResult.normalized_result?.procedure?.address !== "0x1000" + ) + throw new Error( + `packaged function CLI failed: ${JSON.stringify(functionResult)}`, + ); + const xrefs = json( + await run( + cli, + ["xrefs", process.execPath, "0x1000", "--json"], + environment, + ), + ); + if ( + xrefs.operation !== "xrefs" || + JSON.stringify(xrefs.normalized_result) !== JSON.stringify(["0x1000"]) + ) + throw new Error(`packaged xrefs CLI failed: ${JSON.stringify(xrefs)}`); + const trace = json( + await run( + cli, + ["trace", process.execPath, "fixture", "--json"], + environment, + ), + ); + if (trace.operation !== "trace_feature") + throw new Error(`packaged trace CLI failed: ${JSON.stringify(trace)}`); + const capabilities = json( + await run(cli, ["capabilities", "--json"], environment), + ); + if (!Array.isArray(capabilities.capabilities)) + throw new Error("packaged capabilities CLI failed"); + const providers = json(await run(cli, ["providers", "--json"], environment)); + if ( + !Array.isArray(providers.providers) || + providers.providers.some(({ id }) => typeof id !== "string") + ) + throw new Error("packaged providers CLI failed"); + const searchResult = json( + await run( + cli, + ["search", process.execPath, "fixture", "--json"], + environment, + ), + ); + if ( + searchResult.operation !== "search_strings" || + searchResult.normalized_result?.items?.length !== 1 + ) + throw new Error( + `packaged search CLI failed: ${JSON.stringify(searchResult)}`, + ); const referenceImport = json( await run( cli, @@ -201,14 +289,25 @@ try { environment, ), ); + const comparedBundle = json( + await run( + cli, + ["compare", sourceBundlePath, canonicalBundlePath, "--json"], + environment, + ), + ); if ( importedBundle.imported !== 1 || importedBundle.total !== 1 || exportedBundle.records !== 1 || (await readFile(canonicalBundlePath, "utf8")) !== - serializeEvidenceBundle(sourceBundle) + serializeEvidenceBundle(sourceBundle) || + comparedBundle.status !== "unchanged" || + comparedBundle.summary?.records_unchanged !== 1 ) - throw new Error("packaged CLI evidence bundle round trip failed"); + throw new Error( + "packaged CLI evidence bundle comparison round trip failed", + ); await run(cli, ["mcp", "add"], environment); const mcpRegistration = await readFile(npxLog, "utf8"); if (!mcpRegistration.includes("add-mcp npx -y rea-agents mcp --name rea")) diff --git a/src/application/BinarySession.ts b/src/application/BinarySession.ts index ebf44969a..ea7fb4a42 100644 --- a/src/application/BinarySession.ts +++ b/src/application/BinarySession.ts @@ -316,6 +316,14 @@ export class BinarySession implements BinarySessionPort { name: this.#providerIdentity.name, version: this.#providerIdentity.version, }; + const providers = new Map(); + if (this.#capabilities === undefined) providers.set(provider.id, provider); + else + for (const descriptor of this.#capabilities.values()) + providers.set(descriptor.provider.id, descriptor.provider); + const providerList = [...providers.values()] + .sort((left, right) => left.id.localeCompare(right.id)) + .map(({ id, name, version }) => ({ id, name, version })); const capabilities = this.#capabilities === undefined ? [] @@ -348,10 +356,11 @@ export class BinarySession implements BinarySessionPort { limitations: [...descriptor.limitations], })); return target === undefined - ? { open: false, provider, capabilities } + ? { open: false, provider, providers: providerList, capabilities } : { open: true, provider, + providers: providerList, capabilities, path: target.path, sha256: target.sha256, diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index 07e299e4d..b7f605f83 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -20,7 +20,14 @@ const WORKFLOW_PROVIDER = { */ export const runDirectAnalysis = async ( path: string, - tool: "binary_overview" | "procedure_pseudo_code", + tool: + | "binary_overview" + | "procedure_pseudo_code" + | "analyze_function" + | "search_strings" + | "search_procedures" + | "xrefs" + | "trace_feature", arguments_: Readonly>, logger: Logger = silentLogger, ): Promise => runAnalysis(path, tool, arguments_, logger); @@ -33,13 +40,33 @@ export const runProviderAnalysis = async ( logger: Logger = silentLogger, ): Promise => runAnalysis(path, tool, arguments_, logger); +/** Describe configured providers without opening a target or launching Hopper. */ +export const runSessionStatus = async ( + logger: Logger = silentLogger, +): Promise => { + const config = parseConfig(process.env); + if (!config.ok) + return { error: config.error._tag, message: config.error.message }; + const session = createBinarySession(config.value, logger); + try { + return session.status(); + } finally { + await session.close(); + } +}; + const runAnalysis = async ( path: string, tool: | NativeToolName | ArtifactToolName | "binary_overview" - | "procedure_pseudo_code", + | "procedure_pseudo_code" + | "analyze_function" + | "search_strings" + | "search_procedures" + | "xrefs" + | "trace_feature", arguments_: Readonly>, logger: Logger, ): Promise => { @@ -51,7 +78,11 @@ const runAnalysis = async ( const opened = await session.open(path); if (!opened.ok) return { error: opened.error._tag, message: opened.error.message }; - if (tool === "binary_overview") { + if ( + tool === "binary_overview" || + tool === "analyze_function" || + tool === "trace_feature" + ) { const result = await new EnhancedTools(session).execute(tool, arguments_); return result.ok ? createEvidence(opened.value, WORKFLOW_PROVIDER, { diff --git a/src/application/EvidenceBundleCommands.ts b/src/application/EvidenceBundleCommands.ts index 76860677f..3b1a487b0 100644 --- a/src/application/EvidenceBundleCommands.ts +++ b/src/application/EvidenceBundleCommands.ts @@ -1,10 +1,15 @@ -import type { AnalysisError } from "../domain/errors.js"; +import { + EvidenceIntegrityError, + type AnalysisError, +} from "../domain/errors.js"; import type { EvidenceFilePolicy, EvidenceBundle, } from "../domain/evidenceBundle.js"; import type { JsonValue } from "../domain/jsonValue.js"; +import { jsonValueSchema } from "../domain/jsonValue.js"; import { err, ok, type Result } from "../domain/result.js"; +import { compareBundles } from "../domain/bundleComparison.js"; import { EvidenceLedger } from "./EvidenceLedger.js"; import { readEvidenceBundle, @@ -40,6 +45,35 @@ export const exportEvidenceBundleCommand = async ( ); }; +/** Compare two validated canonical Evidence v2 bundles without session state. */ +export const compareEvidenceBundlesCommand = async (input: { + readonly leftPath: string; + readonly rightPath: string; + readonly offset: number; + readonly limit: number; + readonly policy: EvidenceFilePolicy; +}): Promise> => { + const [left, right] = await Promise.all([ + readEvidenceBundle(input.leftPath, input.policy), + readEvidenceBundle(input.rightPath, input.policy), + ]); + if (!left.ok) return left; + if (!right.ok) return right; + try { + return ok( + jsonValueSchema.parse( + compareBundles(left.value, right.value, [], input.offset, input.limit), + ), + ); + } catch (cause: unknown) { + return err( + new EvidenceIntegrityError("Evidence bundle comparison failed", { + cause, + }), + ); + } +}; + const createLedger = (): EvidenceLedger => new EvidenceLedger({ maxRecords: 10_000, maxBytes: 64 * 1024 * 1024 }); diff --git a/src/cli.ts b/src/cli.ts index 143a6083f..a16b049d7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,17 +5,16 @@ import { runDoctor } from "./application/Doctor.js"; import { runDirectAnalysis, runProviderAnalysis, + runSessionStatus, } from "./application/DirectAnalysis.js"; import { runSetup } from "./application/Setup.js"; import { runUninstall } from "./application/Uninstall.js"; import { PRODUCT_IDENTITY } from "./identity.js"; import { createLogger, parseLogLevel, type Logger } from "./logger.js"; +import { logCliCommand } from "./cliLogging.js"; import { parseConfig } from "./config.js"; -import { - exportEvidenceBundleCommand, - importEvidenceBundleCommand, -} from "./application/EvidenceBundleCommands.js"; import { importReferenceSource } from "./application/ReferenceSourceImport.js"; +import { registerEvidenceCommands } from "./cliEvidenceCommands.js"; /** * Build the one-shot Incur CLI without starting Hopper at import time. @@ -49,6 +48,27 @@ export const createCli = (): ReturnType => { }, }); + registerCoreCommands(cli, logger); + registerFunctionCommand(cli, logger); + registerSearchCommand(cli, logger); + registerXrefsCommand(cli, logger); + registerTraceCommand(cli, logger); + registerCapabilityCommands(cli, logger); + registerNativeCommands(cli, logger); + registerArtifactCommands(cli, logger); + registerEvidenceCommands(cli, logger); + registerReferenceSourceCommand(cli, logger); + return cli; +}; + +const registerCoreCommands = ( + cli: ReturnType, + logger: Logger, +): void => { + const overviewOptions = z.object({ + detail: z.enum(["concise", "detailed"]).default("concise"), + limit: z.number().int().min(1).max(50).default(10), + }); cli.command("setup", { description: "Install requirements and configure coding agents", options: z.object({ @@ -86,9 +106,31 @@ export const createCli = (): ReturnType => { args: z.object({ path: z.string().describe("App, program, or Hopper database path"), }), - run: ({ args }) => + options: overviewOptions, + run: ({ args, options }) => logCliCommand(logger, "analyze", () => - runDirectAnalysis(args.path, "binary_overview", {}, logger), + runDirectAnalysis( + args.path, + "binary_overview", + { detail: options.detail, limit: options.limit }, + logger, + ), + ), + }); + cli.command("inspect", { + description: "Inspect an app overview with evidence", + args: z.object({ + path: z.string().describe("App, program, or Hopper database path"), + }), + options: overviewOptions, + run: ({ args, options }) => + logCliCommand(logger, "inspect", () => + runDirectAnalysis( + args.path, + "binary_overview", + { detail: options.detail, limit: options.limit }, + logger, + ), ), }); cli.command("decompile", { @@ -107,11 +149,154 @@ export const createCli = (): ReturnType => { ), ), }); - registerNativeCommands(cli, logger); - registerArtifactCommands(cli, logger); - registerEvidenceCommands(cli, logger); - registerReferenceSourceCommand(cli, logger); - return cli; +}; + +const registerXrefsCommand = ( + cli: ReturnType, + logger: Logger, +): void => { + cli.command("xrefs", { + description: "List bounded references to an analyzed address", + args: z.object({ + path: z.string().describe("App or program path"), + address: z.string().describe("Hexadecimal address"), + }), + run: ({ args }) => + logCliCommand(logger, "xrefs", () => + runDirectAnalysis( + args.path, + "xrefs", + { address: args.address }, + logger, + ), + ), + }); +}; + +const registerTraceCommand = ( + cli: ReturnType, + logger: Logger, +): void => { + cli.command("trace", { + description: "Trace a bounded literal feature through analyzed references", + args: z.object({ + path: z.string().describe("App or program path"), + query: z.string().min(1).describe("Literal feature query"), + }), + options: z.object({ + caseSensitive: z.boolean().default(false), + limit: z.number().int().min(1).max(100).default(20), + maxOperations: z.number().int().min(1).max(100).default(20), + }), + alias: { + caseSensitive: "case-sensitive", + maxOperations: "max-operations", + }, + run: ({ args, options }) => + logCliCommand(logger, "trace", () => + runDirectAnalysis( + args.path, + "trace_feature", + { + query: args.query, + case_sensitive: options.caseSensitive, + limit: options.limit, + max_operations: options.maxOperations, + }, + logger, + ), + ), + }); +}; + +const registerCapabilityCommands = ( + cli: ReturnType, + logger: Logger, +): void => { + for (const command of ["capabilities", "providers"] as const) { + cli.command(command, { + description: + command === "capabilities" + ? "List provider capabilities and side effects" + : "List configured analysis providers", + run: () => logCliCommand(logger, command, () => runSessionStatus(logger)), + }); + } +}; + +const registerFunctionCommand = ( + cli: ReturnType, + logger: Logger, +): void => { + cli.command("function", { + description: "Analyze one bounded function with evidence", + args: z.object({ + path: z.string().describe("App or program path"), + address: z.string().describe("Procedure name or address"), + }), + options: z.object({ + includeAssembly: z.boolean().default(false), + limit: z.number().int().min(1).max(500).default(100), + maxPseudocodeChars: z.number().int().min(1).max(100_000).default(20_000), + maxInstructions: z.number().int().min(1).max(5_000).default(500), + }), + alias: { + includeAssembly: "include-assembly", + maxPseudocodeChars: "max-pseudocode-chars", + maxInstructions: "max-instructions", + }, + run: ({ args, options }) => + logCliCommand(logger, "function", () => + runDirectAnalysis( + args.path, + "analyze_function", + { + procedure: args.address, + include_assembly: options.includeAssembly, + limit: options.limit, + max_pseudocode_chars: options.maxPseudocodeChars, + max_instructions: options.maxInstructions, + }, + logger, + ), + ), + }); +}; + +const registerSearchCommand = ( + cli: ReturnType, + logger: Logger, +): void => { + cli.command("search", { + description: "Search bounded analyzed strings or procedure names", + args: z.object({ + path: z.string().describe("App or program path"), + pattern: z.string().min(1).describe("Literal text or regex pattern"), + }), + options: z.object({ + kind: z.enum(["strings", "procedures"]).default("strings"), + mode: z.enum(["literal", "regex"]).default("literal"), + caseSensitive: z.boolean().default(false), + offset: z.number().int().min(0).default(0), + limit: z.number().int().min(1).max(100).default(100), + }), + alias: { caseSensitive: "case-sensitive" }, + run: ({ args, options }) => + logCliCommand(logger, "search", () => + runDirectAnalysis( + args.path, + options.kind === "strings" ? "search_strings" : "search_procedures", + { + pattern: args.pattern, + mode: options.mode, + case_sensitive: options.caseSensitive, + offset: options.offset, + limit: options.limit, + }, + logger, + ), + ), + }); }; const registerReferenceSourceCommand = ( @@ -253,83 +438,3 @@ const registerNativeCommands = ( ), }); }; - -const registerEvidenceCommands = ( - cli: ReturnType, - logger: Logger, -): void => { - cli.command("evidence-import", { - description: "Validate and import a bounded local Evidence v2 bundle", - args: z.object({ - path: z.string().describe("Evidence bundle JSON path"), - }), - run: ({ args }) => - logCliCommand(logger, "evidence-import", async () => { - const config = parseConfig(process.env); - if (!config.ok) - return { error: config.error._tag, message: config.error.message }; - const imported = await importEvidenceBundleCommand( - args.path, - config.value.evidenceFilePolicy, - ); - return imported.ok - ? imported.value - : { error: imported.error._tag, message: imported.error.message }; - }), - }); - cli.command("evidence-export", { - description: "Validate and atomically export canonical Evidence v2 JSON", - args: z.object({ - source: z.string().describe("Existing evidence bundle JSON path"), - output: z.string().describe("Canonical output JSON path"), - }), - options: z.object({ - overwrite: z.boolean().default(false).describe("Replace output file"), - }), - run: ({ args, options }) => - logCliCommand(logger, "evidence-export", async () => { - const config = parseConfig(process.env); - if (!config.ok) - return { error: config.error._tag, message: config.error.message }; - const exported = await exportEvidenceBundleCommand( - args.source, - args.output, - options.overwrite, - config.value.evidenceFilePolicy, - ); - return exported.ok - ? exported.value - : { error: exported.error._tag, message: exported.error.message }; - }), - }); -}; - -const logCliCommand = async ( - logger: Logger, - command: string, - execute: () => Promise, -): Promise => { - const startedAt = performance.now(); - try { - const value = await execute(); - logger.info( - { - command, - durationMs: Math.round((performance.now() - startedAt) * 100) / 100, - status: "ok", - }, - "CLI command completed", - ); - return value; - } catch (cause: unknown) { - logger.error( - { - command, - durationMs: Math.round((performance.now() - startedAt) * 100) / 100, - status: "error", - }, - "CLI command failed", - ); - throw cause; - } -}; diff --git a/src/cliEvidenceCommands.ts b/src/cliEvidenceCommands.ts new file mode 100644 index 000000000..82ddca626 --- /dev/null +++ b/src/cliEvidenceCommands.ts @@ -0,0 +1,89 @@ +import { Cli, z } from "incur"; + +import { + compareEvidenceBundlesCommand, + exportEvidenceBundleCommand, + importEvidenceBundleCommand, +} from "./application/EvidenceBundleCommands.js"; +import { parseConfig } from "./config.js"; +import { logCliCommand } from "./cliLogging.js"; +import type { Logger } from "./logger.js"; + +/** Register filesystem-gated Evidence v2 commands shared by package smoke tests. */ +export const registerEvidenceCommands = ( + cli: ReturnType, + logger: Logger, +): void => { + cli.command("evidence-import", { + description: "Validate and import a bounded local Evidence v2 bundle", + args: z.object({ + path: z.string().describe("Evidence bundle JSON path"), + }), + run: ({ args }) => + logCliCommand(logger, "evidence-import", async () => { + const config = parseConfig(process.env); + if (!config.ok) + return { error: config.error._tag, message: config.error.message }; + const imported = await importEvidenceBundleCommand( + args.path, + config.value.evidenceFilePolicy, + ); + return imported.ok + ? imported.value + : { error: imported.error._tag, message: imported.error.message }; + }), + }); + cli.command("evidence-export", { + description: "Validate and atomically export canonical Evidence v2 JSON", + args: z.object({ + source: z.string().describe("Existing evidence bundle JSON path"), + output: z.string().describe("Canonical output JSON path"), + }), + options: z.object({ + overwrite: z.boolean().default(false).describe("Replace output file"), + }), + run: ({ args, options }) => + logCliCommand(logger, "evidence-export", async () => { + const config = parseConfig(process.env); + if (!config.ok) + return { error: config.error._tag, message: config.error.message }; + const exported = await exportEvidenceBundleCommand( + args.source, + args.output, + options.overwrite, + config.value.evidenceFilePolicy, + ); + return exported.ok + ? exported.value + : { error: exported.error._tag, message: exported.error.message }; + }), + }); + cli.command("compare", { + aliases: ["compare-bundles"], + description: "Compare two canonical Evidence v2 bundles", + args: z.object({ + left: z.string().describe("Left Evidence bundle JSON path"), + right: z.string().describe("Right Evidence bundle JSON path"), + }), + options: z.object({ + offset: z.number().int().min(0).default(0), + limit: z.number().int().min(1).max(500).default(100), + }), + run: ({ args, options }) => + logCliCommand(logger, "compare", async () => { + const config = parseConfig(process.env); + if (!config.ok) + return { error: config.error._tag, message: config.error.message }; + const compared = await compareEvidenceBundlesCommand({ + leftPath: args.left, + rightPath: args.right, + offset: options.offset, + limit: options.limit, + policy: config.value.evidenceFilePolicy, + }); + return compared.ok + ? compared.value + : { error: compared.error._tag, message: compared.error.message }; + }), + }); +}; diff --git a/src/cliLogging.ts b/src/cliLogging.ts new file mode 100644 index 000000000..4f960c3d3 --- /dev/null +++ b/src/cliLogging.ts @@ -0,0 +1,32 @@ +import type { Logger } from "./logger.js"; + +/** Log one CLI command with duration and a stable success or failure status. */ +export const logCliCommand = async ( + logger: Logger, + command: string, + execute: () => Promise, +): Promise => { + const startedAt = performance.now(); + try { + const value = await execute(); + logger.info( + { + command, + durationMs: Math.round((performance.now() - startedAt) * 100) / 100, + status: "ok", + }, + "CLI command completed", + ); + return value; + } catch (cause: unknown) { + logger.error( + { + command, + durationMs: Math.round((performance.now() - startedAt) * 100) / 100, + status: "error", + }, + "CLI command failed", + ); + throw cause; + } +}; diff --git a/tests/binarySession.test.ts b/tests/binarySession.test.ts index 7a0b2e214..b5407eae9 100644 --- a/tests/binarySession.test.ts +++ b/tests/binarySession.test.ts @@ -79,6 +79,7 @@ describe("binary session", () => { expect(provider.capabilities()[0]?.operation).toBe("address_name"); expect(session.status()).toMatchObject({ provider: { id: "fixture", name: "Fixture", version: "1" }, + providers: [{ id: "fixture", name: "Fixture", version: "1" }], capabilities: [ { operation: "address_name", diff --git a/tests/evidenceBundleFiles.test.ts b/tests/evidenceBundleFiles.test.ts index 28546ac24..76ff41694 100644 --- a/tests/evidenceBundleFiles.test.ts +++ b/tests/evidenceBundleFiles.test.ts @@ -15,6 +15,7 @@ import { readEvidenceBundle, writeEvidenceBundle, } from "../src/application/EvidenceBundleFiles.js"; +import { compareEvidenceBundlesCommand } from "../src/application/EvidenceBundleCommands.js"; import { createEvidence } from "../src/domain/evidence.js"; import { createEvidenceBundle, @@ -66,6 +67,21 @@ describe("evidence bundle filesystem adapter", () => { ok: true, value: evidenceBundle, }); + expect( + await compareEvidenceBundlesCommand({ + leftPath: path, + rightPath: path, + offset: 0, + limit: 100, + policy: policy(directory), + }), + ).toMatchObject({ + ok: true, + value: { + status: "unchanged", + summary: { records_unchanged: 1 }, + }, + }); expect( await writeEvidenceBundle(evidenceBundle, path, false, policy(directory)), ).toMatchObject({ From bce3398dbe3a3b20952f37573a656aa77b168977 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 13 Jul 2026 03:15:27 +0000 Subject: [PATCH 04/12] fix(process): default capture networking to loopback --- src/domain/processCapture.ts | 2 +- tests/processCapture.test.ts | 20 +++++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/domain/processCapture.ts b/src/domain/processCapture.ts index ed8554b86..d6ae99d8d 100644 --- a/src/domain/processCapture.ts +++ b/src/domain/processCapture.ts @@ -42,7 +42,7 @@ export const processScenarioSchema = z environment: z.record(environmentName, z.string()).default({}), inherit_environment: z.array(environmentName).max(64).default([]), secret_aliases: z.array(environmentName).max(64).default([]), - network_access: z.literal("host").default("host"), + network_access: z.enum(["loopback", "host"]).default("loopback"), filesystem_roots: z.array(z.string().startsWith("/")).max(16).default([]), events: z .array( diff --git a/tests/processCapture.test.ts b/tests/processCapture.test.ts index b00106b38..2f3edbae9 100644 --- a/tests/processCapture.test.ts +++ b/tests/processCapture.test.ts @@ -72,8 +72,12 @@ describe("process capture domain", () => { }); it("requires explicit operator approval for host network access", () => { + const scenario = parseProcessScenario({ + ...base, + network_access: "host", + }); expect( - authorizeProcessScenario(parseProcessScenario(base), { + authorizeProcessScenario(scenario, { enabled: true, executableRoots: ["/bin"], workingRoots: ["/tmp"], @@ -86,6 +90,20 @@ describe("process capture domain", () => { }); }); + it("allows loopback replay without external network approval", () => { + const scenario = parseProcessScenario(base); + expect(scenario.network_access).toBe("loopback"); + expect( + authorizeProcessScenario(scenario, { + enabled: true, + executableRoots: ["/bin"], + workingRoots: ["/tmp"], + allowedEnvironment: [], + allowExternalNetwork: false, + }), + ).toEqual({ allowed: true }); + }); + it("refuses paths and environment outside operator policy", () => { const scenario = parseProcessScenario({ ...base, From 0d369762770d2d92dd72c7dcb2f2f90a64b206ff Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 13 Jul 2026 03:15:46 +0000 Subject: [PATCH 05/12] test(verification): strengthen conformance and real-Hopper checks --- scripts/verify-conformance-fixtures.mjs | 23 ++++++++++++++++++++++- scripts/verify-real-hopper.mjs | 15 +++++++++++++++ src/application/RealHopperAssertions.ts | 17 +++++++++++++++++ tests/realHopperAssertions.test.ts | 8 ++++++++ 4 files changed, 62 insertions(+), 1 deletion(-) diff --git a/scripts/verify-conformance-fixtures.mjs b/scripts/verify-conformance-fixtures.mjs index 7cf09addc..2151a67db 100644 --- a/scripts/verify-conformance-fixtures.mjs +++ b/scripts/verify-conformance-fixtures.mjs @@ -3,7 +3,11 @@ import { readFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { promisify } from "node:util"; -import { sha256 } from "./lib/conformance-fixtures.mjs"; +import { + generateLargeFixture, + sha256, + sourceDigest, +} from "./lib/conformance-fixtures.mjs"; const exec = promisify(execFile); const manifestPath = resolve( @@ -16,6 +20,23 @@ const root = dirname(manifestPath); for (const fixture of manifest.fixtures) { const artifactPath = join(root, fixture.artifact); + const sources = []; + for (const source of fixture.sources ?? []) { + const content = + source.path === "generated/large.c" + ? generateLargeFixture() + : await readFile(join(process.cwd(), "tests/conformance", source.path)); + if (sha256(content) !== source.sha256) + throw new Error( + `${fixture.name}: source hash mismatch for ${source.path}`, + ); + sources.push({ path: source.path, content }); + } + if ( + fixture.sourceSha256 !== undefined && + sourceDigest(sources) !== fixture.sourceSha256 + ) + throw new Error(`${fixture.name}: source manifest digest mismatch`); const artifact = await readFile(artifactPath); if (sha256(artifact) !== fixture.artifactSha256) throw new Error(`${fixture.name}: artifact hash mismatch`); diff --git a/scripts/verify-real-hopper.mjs b/scripts/verify-real-hopper.mjs index c45a79db8..51cd77f1f 100644 --- a/scripts/verify-real-hopper.mjs +++ b/scripts/verify-real-hopper.mjs @@ -1,4 +1,6 @@ import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; import { readdir, realpath } from "node:fs/promises"; import { promisify } from "node:util"; @@ -8,6 +10,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js"; import { firstProcedureAddress, + requireDistinctTargetHashes, requireAddressArray, requireFunctionDossier, requirePseudocode, @@ -253,6 +256,11 @@ const [targetA, targetB] = await Promise.all([ ]); if (targetA === targetB) throw new Error("Real-Hopper verification requires two distinct targets"); +const [targetHashA, targetHashB] = await Promise.all([ + sha256File(targetA), + sha256File(targetB), +]); +requireDistinctTargetHashes(targetHashA, targetHashB); const serverEnvironment = { ...process.env }; delete serverEnvironment.HOPPER_TARGET_PATH; delete serverEnvironment.HOPPER_SECOND_TARGET_PATH; @@ -377,6 +385,7 @@ try { diagnosticCount, dynamicSession: true, targets: [targetA, targetB], + targetHashes: [targetHashA, targetHashB], switched: true, secondOverview: verifiedSecondOverview, }; @@ -422,3 +431,9 @@ await new Promise((resolve, reject) => { }, ); }); + +async function sha256File(path) { + const hash = createHash("sha256"); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return hash.digest("hex"); +} diff --git a/src/application/RealHopperAssertions.ts b/src/application/RealHopperAssertions.ts index 1e815f865..45e12d22b 100644 --- a/src/application/RealHopperAssertions.ts +++ b/src/application/RealHopperAssertions.ts @@ -27,6 +27,23 @@ export const firstProcedureAddress = (input: unknown): string => { return first.address; }; +/** Reject two target paths that resolve to the same binary content. */ +export const requireDistinctTargetHashes = ( + firstHash: unknown, + secondHash: unknown, +): void => { + if ( + typeof firstHash !== "string" || + typeof secondHash !== "string" || + firstHash.length === 0 || + secondHash.length === 0 + ) { + throw new TypeError("Target hashes must be non-empty strings"); + } + if (firstHash === secondHash) + throw new TypeError("Real-Hopper verification requires distinct binaries"); +}; + /** Reject empty and success-shaped embedded decompilation failures. */ export const requirePseudocode = ( input: unknown, diff --git a/tests/realHopperAssertions.test.ts b/tests/realHopperAssertions.test.ts index 0140ac591..fe6d0424e 100644 --- a/tests/realHopperAssertions.test.ts +++ b/tests/realHopperAssertions.test.ts @@ -2,12 +2,20 @@ import { describe, expect, it } from "vitest"; import { firstProcedureAddress, + requireDistinctTargetHashes, requireAddressArray, requireFunctionDossier, requirePseudocode, } from "../src/application/RealHopperAssertions.js"; describe("real Hopper semantic assertions", () => { + it("rejects two target paths containing the same binary", () => { + expect(() => requireDistinctTargetHashes("same", "same")).toThrow( + /distinct binaries/u, + ); + expect(() => requireDistinctTargetHashes("", "other")).toThrow(); + expect(() => requireDistinctTargetHashes("first", "second")).not.toThrow(); + }); it("extracts only a real procedure address from a structured page", () => { expect( firstProcedureAddress({ From efc9bd270f58a5aaba15edb07cee135d2c54db83 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 13 Jul 2026 03:16:02 +0000 Subject: [PATCH 06/12] docs: document CLI safety and provider evaluation --- README.md | 34 ++++++++++++++------- README_ar.md | 3 +- docs/provider-evaluation.md | 59 +++++++++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 11 deletions(-) create mode 100644 docs/provider-evaluation.md diff --git a/README.md b/README.md index 89607b6b7..496fe21e6 100644 --- a/README.md +++ b/README.md @@ -170,12 +170,12 @@ Uninstall preserves Hopper, Homebrew, Node.js, evidence, captures, external evid ### CLI or coding agent? -| If you want to… | Use | -| --------------------------------------------------------- | ---------------------------------------------- | -| Ask an agent to investigate an app and build a feature | Install the skill, then talk to your agent | -| Inspect or decompile one part of an app from the Terminal | `rea analyze` or `rea decompile` | -| Validate or canonicalize an Evidence v2 bundle | `rea evidence-import` or `rea evidence-export` | -| Import source as historical reference | `rea import-reference-source` | +| If you want to… | Use | +| --------------------------------------------------------- | -------------------------------------------------------------- | +| Ask an agent to investigate an app and build a feature | Install the skill, then talk to your agent | +| Inspect or decompile one part of an app from the Terminal | `rea analyze` or `rea decompile` | +| Validate, canonicalize, or compare Evidence v2 bundles | `rea evidence-import`, `rea evidence-export`, or `rea compare` | +| Import source as historical reference | `rea import-reference-source` | Filesystem evidence commands and MCP file tools are disabled until the operator approves absolute roots: @@ -183,6 +183,7 @@ Filesystem evidence commands and MCP file tools are disabled until the operator export REA_EVIDENCE_ROOTS_JSON='["/absolute/path/to/evidence"]' rea evidence-import /absolute/path/to/evidence/bundle.json rea evidence-export /absolute/path/to/evidence/bundle.json /absolute/path/to/evidence/canonical.json +rea compare /absolute/path/to/evidence/left.json /absolute/path/to/evidence/right.json ``` Historical source import requires a separate allowlist and never treats source as current behavioral authority: @@ -276,6 +277,8 @@ REA is growing into a toolkit for understanding software across static artifacts Roadmap items describe direction, not shipped support. New providers must produce the same evidence and safety metadata as existing capabilities before they become part of the public workflow. +See the [static-analysis provider evaluation](docs/provider-evaluation.md) for the current research matrix and admission gate. + ## Using REA with other coding agents Setup currently configures Claude Desktop and Cursor automatically. Any coding agent that supports local MCP servers can use REA with the configuration below. @@ -320,9 +323,20 @@ The agent workflow above is the easiest way to use REA. For a one-off overview f ```bash npx -y rea-agents analyze /Applications/Notes.app +npx -y rea-agents inspect /Applications/Notes.app +npx -y rea-agents inspect /Applications/Notes.app --detail detailed --limit 20 +npx -y rea-agents search /Applications/Notes.app "offline" +npx -y rea-agents function /Applications/Notes.app 0x1000 +npx -y rea-agents xrefs /Applications/Notes.app 0x1000 +npx -y rea-agents trace /Applications/Notes.app "offline" +npx -y rea-agents compare /absolute/path/to/left-evidence.json /absolute/path/to/right-evidence.json +npx -y rea-agents capabilities +npx -y rea-agents providers ``` -Run `npx -y rea-agents --help` for direct decompilation and other options. +Run `npx -y rea-agents --help` for direct decompilation, bounded search and +other options. `analyze` and `inspect` share the same overview workflow; +`function`, `xrefs`, and `trace` return the same Evidence v2 envelopes as MCP. Or install the `rea` command globally: @@ -347,9 +361,9 @@ Closing a REA session shuts down its bridge and removes its private socket direc Process capture is disabled by default. Enabling it requires `REA_PROCESS_CAPTURE_ENABLED=true`, approved executable and working roots in `REA_PROCESS_EXECUTABLE_ROOTS_JSON` and `REA_PROCESS_WORKING_ROOTS_JSON`, and an -environment allowlist in `REA_PROCESS_ALLOWED_ENV_JSON`. Because the current PTY -adapter uses host networking, it also requires -`REA_PROCESS_ALLOW_EXTERNAL_NETWORK=true`. +environment allowlist in `REA_PROCESS_ALLOWED_ENV_JSON`. Scenarios use the +owned loopback replay by default; a scenario requesting host networking also +requires `REA_PROCESS_ALLOW_EXTERNAL_NETWORK=true`. If the native PTY backend is unavailable, install Xcode command-line tools and run `npm run rebuild:native`. Linux source builds require Python, `make`, and a diff --git a/README_ar.md b/README_ar.md index e318a9041..65b1e4dba 100644 --- a/README_ar.md +++ b/README_ar.md @@ -199,6 +199,7 @@ flowchart LR ```bash npx -y rea-agents analyze /Applications/Notes.app +npx -y rea-agents compare /absolute/path/to/left-evidence.json /absolute/path/to/right-evidence.json ``` شغّل `npx -y rea-agents --help` لمعرفة خيارات فك الترجمة المباشر والخيارات الأخرى. @@ -243,7 +244,7 @@ export HOPPER_LOADER_ARGS_JSON='["-l", "Mach-O", "--aarch68"]'
هل يعمل REA على Linux أو Windows؟ -ليس حاليًا. يعتمد المحول الحالي على تطبيق Hopper لنظام macOS وواجهة Python الخاصة به. +نعم، يعمل REA على macOS 12+ وUbuntu 24.04+ وFedora 41+ وArch Linux ‏64 بت؛ Windows غير مدعوم حاليًا. يعتمد التحليل العميق على تطبيق Hopper وواجهة Python الخاصة به.
diff --git a/docs/provider-evaluation.md b/docs/provider-evaluation.md new file mode 100644 index 000000000..21d509e2e --- /dev/null +++ b/docs/provider-evaluation.md @@ -0,0 +1,59 @@ +# Static-analysis provider evaluation + +Status: research for issue #26; no provider implementation is approved by this note. + +REA should add another analysis provider only after the provider-neutral capability +contracts, Evidence v2 metadata, ownership rules, and multi-fixture conformance +suite are complete. A provider is not a drop-in replacement for Hopper: every +capability must be mapped explicitly, with truthful `unavailable` results where +the engine cannot provide equivalent semantics. + +## Shortlist + +| Provider | License / automation surface | What it brings | REA fit and blockers | +| ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [Ghidra](https://github.com/NationalSecurityAgency/ghidra) | Apache-2.0 source license; `analyzeHeadless`, Java APIs, and PyGhidra | Broad static analysis, many processors and formats, scripting, and project/database workflows | Strong candidate for a full provider. Requires a Java/JDK runtime, isolated project/cache directories, process supervision, and a stable adapter for Ghidra's project-oriented model. | +| [Rizin](https://github.com/rizinorg/rizin) / [rz-pipe](https://github.com/rizinorg/rz-pipe) | Rizin repository contains LGPL-3.0 and GPL-3.0 components; `rizin`, `rz-bin`, and language bridges through `rzpipe` | Portable CLI analysis, disassembly/debugging, many architectures and file formats, JSON command output | Good candidate for a process-backed Linux provider and fast metadata fallback. License/component inventory must be preserved; command output needs version-pinned parsers and semantic conformance before evidence is trusted. | +| [LIEF](https://github.com/lief-project/LIEF) | Apache-2.0; C++, Python, and other bindings | Deterministic parsing and modification of ELF, PE, Mach-O, COFF, and related executable formats; headers, sections, symbols, relocations, and functions | Best near-term complement, not a decompiler replacement. It can cover format metadata and artifact evidence without a long-lived analysis process; function semantics, pseudocode, CFG, and cross-reference parity remain out of scope unless separately demonstrated. | +| [Binary Ninja](https://docs.binary.ninja/dev/index.html) | API/documentation components are MIT, while the analysis product is licensed by edition; commercial, Ultimate, or Headless license is required for headless automation | Python/Core/C++/Rust APIs, headless loading, IL layers, function analysis, plugins, and configurable analysis | Strong technical fit for a native provider, especially function dossiers. Commercial licensing, license-secret handling, native runtime packaging, and multithreaded lifecycle rules are material deployment blockers. | + +## Recommended order + +1. Use LIEF first for provider-neutral binary metadata where the current + contracts already describe headers, sections, symbols, and relocations. +2. Evaluate Rizin as a separate process provider for portable disassembly and + command-backed queries, with JSON-only parsing and fixture assertions. +3. Evaluate Ghidra or Binary Ninja for decompilation and richer function + semantics only after the conformance fixtures can compare evidence by + capability rather than by provider-specific output text. + +## Required admission gate + +Before implementation, an adapter proposal must provide: + +- a capability matrix covering supported, unsupported, and degraded results; +- provider identity, version, target digest, authority, limitations, and + deterministic locations in every Evidence v2 record; +- bounded subprocess or library lifetime, cancellation, timeouts, and cleanup; +- sanitized diagnostics that do not expose license material, host paths, or + provider tracebacks; +- the same source-owned fixture corpus across architectures and formats; +- repeatable checks for function identity, addresses, strings, names, xrefs, + CFG, and pseudocode wherever those capabilities are claimed. + +This keeps issue #26 a decision record rather than prematurely committing REA +to an engine whose semantics or redistribution terms have not been verified. + +## Primary-source notes + +- Ghidra documents headless batch mode and its Java/Python runtime requirements + in its [Getting Started guide](https://github.com/NationalSecurityAgency/ghidra/blob/master/GhidraDocs/GettingStarted.md). +- Rizin lists supported formats, architectures, tools, and `rzpipe` bridges in + its [repository README](https://github.com/rizinorg/rizin); `rz-pipe` documents + JSON command transport and pipe backends. +- LIEF's [format tutorial](https://lief.re/doc/stable/tutorials/01_play_with_formats.html) + and [Python API](https://lief.re/doc/latest/api/binary_abstraction/python.html) + document parsing and format-agnostic binary access. +- Binary Ninja documents its [headless automation model](https://docs.binary.ninja/dev/batch.html), + [edition licenses](https://docs.binary.ninja/about/license.html), and the + [MIT license for API/documentation components](https://docs.binary.ninja/about/open-source.html). From a7b84a9b4b7ea4336c0f327e13b2948a8d94fba9 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 13 Jul 2026 03:16:34 +0000 Subject: [PATCH 07/12] test(docs): keep localized claims and tool counts aligned --- tests/readmeConsistency.test.ts | 48 ++++++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/tests/readmeConsistency.test.ts b/tests/readmeConsistency.test.ts index d77293835..332d1c5e4 100644 --- a/tests/readmeConsistency.test.ts +++ b/tests/readmeConsistency.test.ts @@ -19,6 +19,38 @@ const readmes = [ "README_ar.md", ] as const; +const expectedToolCounts = [ + OFFICIAL_TOOL_CONTRACTS.length, + ENHANCED_TOOL_CONTRACTS.length, + NATIVE_TOOL_CONTRACTS.length, + ARTIFACT_TOOL_CONTRACTS.length, + SESSION_TOOL_CONTRACTS.length, +] as const; + +const toolCountsFromReadme = (content: string, path: string): number[] => { + const lines = content.split(/\r?\n/u); + const headingIndex = lines.findIndex((line) => /^## .*68/u.test(line)); + if (headingIndex === -1) + throw new Error(`Missing 68-tool heading in ${path}`); + + const counts: number[] = []; + for (const line of lines.slice(headingIndex + 1)) { + if (line.startsWith("## ")) break; + if (!line.trim().startsWith("|") || /^\|\s*-/.test(line)) continue; + const cells = line.split("|").map((cell) => cell.trim()); + const countCell = cells[2]; + if (countCell === undefined) + throw new Error(`Malformed table row in ${path}`); + const count = Number(countCell); + if (!Number.isInteger(count)) { + if (counts.length === 0) continue; + throw new Error(`Non-numeric tool count in ${path}`); + } + counts.push(count); + } + return counts; +}; + describe("localized README product facts", () => { it.each(readmes)( "keeps commands and requirements aligned in %s", @@ -34,17 +66,13 @@ describe("localized README product facts", () => { expect(content).toContain('"args": ["-y", "rea-agents", "mcp"]'); expect(content).toContain("Node.js 24"); expect(content).toContain("macOS 12"); + expect(content).toContain("Ubuntu 24.04"); + expect(content).toContain("Fedora 41"); + expect(content).toContain("Arch Linux"); + if (path === "README_ar.md") + expect(content).toContain("Windows غير مدعوم حاليًا"); expect(content).toContain(`MCP_tools-${String(TOOL_CONTRACTS.length)}`); - for (const count of [ - OFFICIAL_TOOL_CONTRACTS.length, - ENHANCED_TOOL_CONTRACTS.length, - NATIVE_TOOL_CONTRACTS.length, - ARTIFACT_TOOL_CONTRACTS.length, - SESSION_TOOL_CONTRACTS.length, - ]) - expect(content).toMatch( - new RegExp(`\\|\\s*${String(count)}\\s*\\|`, "u"), - ); + expect(toolCountsFromReadme(content, path)).toEqual(expectedToolCounts); }, ); From d806480e41bcb623448a258ddffeb75082fe1e1f Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 13 Jul 2026 03:25:02 +0000 Subject: [PATCH 08/12] docs: correct pull request tool inventory --- .github/pull_request_template.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index e745d42c2..dda5dd048 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -26,7 +26,7 @@ ## Tool Contract Impact - + ## Related Issues From 329fdf6fe8f3a2bf9679b13a8ce5cb16cf946129 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 13 Jul 2026 03:26:32 +0000 Subject: [PATCH 09/12] refactor(cli): share direct analysis tool types --- src/application/DirectAnalysis.ts | 29 +++++++++++------------------ src/cliEvidenceCommands.ts | 2 +- 2 files changed, 12 insertions(+), 19 deletions(-) diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index b7f605f83..ba0969c51 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -13,6 +13,15 @@ const WORKFLOW_PROVIDER = { version: "1", } as const; +type DirectAnalysisTool = + | "binary_overview" + | "procedure_pseudo_code" + | "analyze_function" + | "search_strings" + | "search_procedures" + | "xrefs" + | "trace_feature"; + /** * Open one binary, execute one tool, and always release the bridge session. * Unlike MCP mode, every CLI invocation is intentionally isolated and does not @@ -20,14 +29,7 @@ const WORKFLOW_PROVIDER = { */ export const runDirectAnalysis = async ( path: string, - tool: - | "binary_overview" - | "procedure_pseudo_code" - | "analyze_function" - | "search_strings" - | "search_procedures" - | "xrefs" - | "trace_feature", + tool: DirectAnalysisTool, arguments_: Readonly>, logger: Logger = silentLogger, ): Promise => runAnalysis(path, tool, arguments_, logger); @@ -57,16 +59,7 @@ export const runSessionStatus = async ( const runAnalysis = async ( path: string, - tool: - | NativeToolName - | ArtifactToolName - | "binary_overview" - | "procedure_pseudo_code" - | "analyze_function" - | "search_strings" - | "search_procedures" - | "xrefs" - | "trace_feature", + tool: NativeToolName | ArtifactToolName | DirectAnalysisTool, arguments_: Readonly>, logger: Logger, ): Promise => { diff --git a/src/cliEvidenceCommands.ts b/src/cliEvidenceCommands.ts index 82ddca626..c602d4309 100644 --- a/src/cliEvidenceCommands.ts +++ b/src/cliEvidenceCommands.ts @@ -9,7 +9,7 @@ import { parseConfig } from "./config.js"; import { logCliCommand } from "./cliLogging.js"; import type { Logger } from "./logger.js"; -/** Register filesystem-gated Evidence v2 commands shared by package smoke tests. */ +/** Register filesystem-gated Evidence v2 commands. */ export const registerEvidenceCommands = ( cli: ReturnType, logger: Logger, From 9526389f815f0937fc01198ff3a8a850c6c1c81a Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 13 Jul 2026 03:30:17 +0000 Subject: [PATCH 10/12] fix(process): keep network approval fail-closed --- README.md | 6 +++--- src/domain/processCapture.ts | 2 +- tests/processCapture.test.ts | 20 +------------------- 3 files changed, 5 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 496fe21e6..639dfb6ea 100644 --- a/README.md +++ b/README.md @@ -361,9 +361,9 @@ Closing a REA session shuts down its bridge and removes its private socket direc Process capture is disabled by default. Enabling it requires `REA_PROCESS_CAPTURE_ENABLED=true`, approved executable and working roots in `REA_PROCESS_EXECUTABLE_ROOTS_JSON` and `REA_PROCESS_WORKING_ROOTS_JSON`, and an -environment allowlist in `REA_PROCESS_ALLOWED_ENV_JSON`. Scenarios use the -owned loopback replay by default; a scenario requesting host networking also -requires `REA_PROCESS_ALLOW_EXTERNAL_NETWORK=true`. +environment allowlist in `REA_PROCESS_ALLOWED_ENV_JSON`. Because the current PTY +adapter uses host networking, it also requires +`REA_PROCESS_ALLOW_EXTERNAL_NETWORK=true`. If the native PTY backend is unavailable, install Xcode command-line tools and run `npm run rebuild:native`. Linux source builds require Python, `make`, and a diff --git a/src/domain/processCapture.ts b/src/domain/processCapture.ts index d6ae99d8d..ed8554b86 100644 --- a/src/domain/processCapture.ts +++ b/src/domain/processCapture.ts @@ -42,7 +42,7 @@ export const processScenarioSchema = z environment: z.record(environmentName, z.string()).default({}), inherit_environment: z.array(environmentName).max(64).default([]), secret_aliases: z.array(environmentName).max(64).default([]), - network_access: z.enum(["loopback", "host"]).default("loopback"), + network_access: z.literal("host").default("host"), filesystem_roots: z.array(z.string().startsWith("/")).max(16).default([]), events: z .array( diff --git a/tests/processCapture.test.ts b/tests/processCapture.test.ts index 2f3edbae9..b00106b38 100644 --- a/tests/processCapture.test.ts +++ b/tests/processCapture.test.ts @@ -72,12 +72,8 @@ describe("process capture domain", () => { }); it("requires explicit operator approval for host network access", () => { - const scenario = parseProcessScenario({ - ...base, - network_access: "host", - }); expect( - authorizeProcessScenario(scenario, { + authorizeProcessScenario(parseProcessScenario(base), { enabled: true, executableRoots: ["/bin"], workingRoots: ["/tmp"], @@ -90,20 +86,6 @@ describe("process capture domain", () => { }); }); - it("allows loopback replay without external network approval", () => { - const scenario = parseProcessScenario(base); - expect(scenario.network_access).toBe("loopback"); - expect( - authorizeProcessScenario(scenario, { - enabled: true, - executableRoots: ["/bin"], - workingRoots: ["/tmp"], - allowedEnvironment: [], - allowExternalNetwork: false, - }), - ).toEqual({ allowed: true }); - }); - it("refuses paths and environment outside operator policy", () => { const scenario = parseProcessScenario({ ...base, From c630f410b6a81864bf90211a088c715bfe234722 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 13 Jul 2026 03:34:57 +0000 Subject: [PATCH 11/12] fix(cli): preserve function provider provenance --- scripts/verify-package.mjs | 1 + src/application/DirectAnalysis.ts | 20 +++++++++++++------- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index 3d2649e71..96564b14b 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -194,6 +194,7 @@ try { ); if ( functionResult.operation !== "analyze_function" || + functionResult.provider?.id !== "hopper" || functionResult.normalized_result?.procedure?.address !== "0x1000" ) throw new Error( diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index ba0969c51..141f71686 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -78,13 +78,19 @@ const runAnalysis = async ( ) { const result = await new EnhancedTools(session).execute(tool, arguments_); return result.ok - ? createEvidence(opened.value, WORKFLOW_PROVIDER, { - operation: tool, - parameters: arguments_, - result: result.value, - confidence: "derived", - limitations: ["Derived by an REA composed workflow."], - }) + ? createEvidence( + opened.value, + tool === "analyze_function" + ? session.providerIdentity(tool) + : WORKFLOW_PROVIDER, + { + operation: tool, + parameters: arguments_, + result: result.value, + confidence: "derived", + limitations: ["Derived by an REA composed workflow."], + }, + ) : { error: result.error._tag, message: result.error.message }; } const result = await session.execute(tool, arguments_); From 7c36f3631862e67a0751967c84abc4f423f5475c Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 13 Jul 2026 03:38:15 +0000 Subject: [PATCH 12/12] fix(analysis): accept valid final dossier pages --- src/domain/hopperValues.ts | 11 ----------- tests/enhancedTools.test.ts | 8 ++------ 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/src/domain/hopperValues.ts b/src/domain/hopperValues.ts index a1e1377c6..7d5e417bb 100644 --- a/src/domain/hopperValues.ts +++ b/src/domain/hopperValues.ts @@ -106,17 +106,6 @@ const boundedSchema = (item: T) => path: ["next_offset"], }); } - if ( - !value.truncated && - value.total !== null && - value.total !== value.returned - ) { - context.addIssue({ - code: "custom", - message: "a complete collection total must equal returned", - path: ["total"], - }); - } }); const referenceEdgeSchema = z .object({ diff --git a/tests/enhancedTools.test.ts b/tests/enhancedTools.test.ts index 0b441c578..765c1ca71 100644 --- a/tests/enhancedTools.test.ts +++ b/tests/enhancedTools.test.ts @@ -568,7 +568,7 @@ describe("enhanced MCP tools", () => { ); }); - it("rejects complete dossier collections with inflated totals", async () => { + it("accepts a final dossier page whose total exceeds returned", async () => { const malformedPort = fixturePort(); const client = await connect({ execute: async (name, arguments_, options) => { @@ -597,10 +597,6 @@ describe("enhanced MCP tools", () => { name: "analyze_function", arguments: { procedure: "0x1" }, }); - expect(result.isError).toBe(true); - const text = result.content.find((item) => item.type === "text"); - expect(text?.type === "text" ? text.text : "").toContain( - "AnalysisOutputError", - ); + expect(result.isError).not.toBe(true); }); });