From e749160ab0e79b1d7555cd893e20ba574a307c8a Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 3 Nov 2026 03:29:59 +0800 Subject: [PATCH 01/14] fix(mcp): qualify string byte-read guidance (#8704) * fix(mcp): qualify string byte-read guidance * fix(mcp): avoid implying complete string references --- src/contracts/officialToolContracts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/contracts/officialToolContracts.ts b/src/contracts/officialToolContracts.ts index 45a42da025..16ba5d0b66 100644 --- a/src/contracts/officialToolContracts.ts +++ b/src/contracts/officialToolContracts.ts @@ -224,7 +224,7 @@ export const OFFICIAL_TOOL_CONTRACTS = [ ), official( "search_strings", - `Search every provider-reported string object or candidate using literal matching by default or regex when requested. Results are deterministic and complete over that provider inventory, not every text sequence in artifact bytes. Kuna scans mapped sequences of at least five characters, including unterminated fragments, without computing references. Ghidra may omit packed or undefined literals, including Rust strings; an empty result does not prove text is absent. If an address is known, read_bytes and xrefs inspect its bytes and references. Hopper retains typed byte decoding, native display, and explicit decoding unknowns. Adjacent long-literal fragments match independently. ${hopperRegexDeadline} Select detail summary for counts and retained native-inventory pages; complete remains the default.`, + `Search every provider-reported string object or candidate using literal matching by default or regex when requested. Results are deterministic and complete over that provider inventory, not every text sequence in artifact bytes. Kuna scans mapped sequences of at least five characters, including unterminated fragments, without computing references. Ghidra may omit packed or undefined literals, including Rust strings; an empty result does not prove text is absent. If an address is known, xrefs can look for references; read_bytes can inspect bytes where supported (not available with Kuna). Hopper retains typed byte decoding, native display, and explicit decoding unknowns. Adjacent long-literal fragments match independently. ${hopperRegexDeadline} Select detail summary for counts and retained native-inventory pages; complete remains the default.`, z.object({ ...nativeSearchInput, detail: analysisDetailSchema }), { evidenceParameters: "omit-detail" }, ), From 9bea563e30fd255d639d6877a91109ba9ff2eb00 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 3 Nov 2026 03:30:19 +0800 Subject: [PATCH 02/14] fix(kuna): qualify parallel project pseudocode (#8694) * fix(kuna): qualify parallel project pseudocode * test(kuna): cover parallel caveats through MCP --- docs/kuna-provider.md | 4 + src/kuna/KunaProjectReader.ts | 7 +- .../providers/kuna/kunaParallelMcp.test.ts | 83 +++++++++++++++++++ .../providers/kuna/kunaProvider.test.ts | 33 ++++++++ 4 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 tests/boundary/providers/kuna/kunaParallelMcp.test.ts diff --git a/docs/kuna-provider.md b/docs/kuna-provider.md index fdbde7526d..cf5199206c 100644 --- a/docs/kuna-provider.md +++ b/docs/kuna-provider.md @@ -48,6 +48,10 @@ completes. Cleanup retries keep both the observations and the exact file owner. | `REA_KUNA_TIMEOUT_MS` | 120 s | Deadline for one single-function decompile, and for the inventory run REA makes when the export ends without one. | | `REA_KUNA_JOBS` | 1 | `decompile-project --jobs`. 1 is Kuna's reference answer; more jobs finish sooner but lose callee-first prototypes, and change about 40–60% of function bodies on the measured binaries. | +Pseudocode read from a parallel project export includes a prototype-context +limitation in its Evidence, including in function dossiers and composed +workflows. Early single-function results have their own source limitation. + A single-function decompile, inventory, string, graph or xref run that exceeds its deadline fails as `provider_timeout`, naming the setting that owns the deadline and that raising it takes a restart. A project export that exceeds `REA_KUNA_PROJECT_TIMEOUT_MS` is an export failure instead: requests that need the export report `capability_unavailable` with the export's own retry guidance. Windows is not supported yet. Supported targets are ELF and thin Mach-O executables for x86-64, AArch64 and ARM; PE and universal Mach-O are rejected with a typed reason. diff --git a/src/kuna/KunaProjectReader.ts b/src/kuna/KunaProjectReader.ts index fb86a7ef9e..f9df8dc7dc 100644 --- a/src/kuna/KunaProjectReader.ts +++ b/src/kuna/KunaProjectReader.ts @@ -350,7 +350,12 @@ export class KunaProjectReader { .slice(text.value.indexOf("\n") + 1) .replace(/\n+$/u, ""), files, - limitations: [], + limitations: + this.project.manifest.inputs.jobs > 1 + ? [ + "Parallel Kuna project export does not propagate callee-first prototypes between workers; function signatures and call arguments may differ from a serial export.", + ] + : [], }); } diff --git a/tests/boundary/providers/kuna/kunaParallelMcp.test.ts b/tests/boundary/providers/kuna/kunaParallelMcp.test.ts new file mode 100644 index 0000000000..a4133e93a9 --- /dev/null +++ b/tests/boundary/providers/kuna/kunaParallelMcp.test.ts @@ -0,0 +1,83 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect } from "vitest"; + +import { AnalysisProviderRegistry } from "../../../../src/application/binary/AnalysisProviderRegistry.js"; +import { composeBinarySession } from "../../../../src/application/binary/BinarySessionComposition.js"; +import { parseEvidence } from "../../../../src/domain/evidence.js"; +import { KunaProvider } from "../../../../src/kuna/KunaProvider.js"; +import { elfNoteFixture } from "../../../../src/native/elf/ElfNotes.fixture.js"; +import { createServer } from "../../../../src/server/createServer.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { mcpTest } from "../../../support/mcp/mcpFixture.js"; +import { writeFakeKuna } from "./fakeKuna.js"; + +const test = mcpTest.skipIf(process.platform === "win32"); +const caveat = + "Parallel Kuna project export does not propagate callee-first prototypes"; + +for (const jobs of [1, 2]) { + test(`retains source limitations in batch and paged dossier with ${String(jobs)} worker(s)`, async ({ + mcp, + }) => { + const root = await createTestTempDirectory("rea-kuna-parallel-mcp-"); + const kuna = await writeFakeKuna(root, { + functions: [{ name: "main", addr: "0x1000" }], + }); + const target = join(root, "program"); + await writeFile(target, elfNoteFixture({ bits: 64, type: 2 })); + const session = composeBinarySession( + new AnalysisProviderRegistry( + [ + new KunaProvider( + { + REA_KUNA_COMMAND: kuna, + REA_KUNA_CACHE_DIR: join(root, "cache"), + REA_KUNA_JOBS: String(jobs), + }, + { platform: process.platform }, + ), + ], + "kuna", + ), + ); + const client = await mcp.connect( + createServer({ kind: "session", session }), + ); + const call = async (name: string, arguments_: Record) => { + const response = await client.callTool({ name, arguments: arguments_ }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + return response.structuredContent; + }; + await call("open_binary", { path: target }); + // Assembly waits for the complete project, avoiding timing-based source selection. + await call("procedure_assembly", { procedure: "main" }); + const batch = parseEvidence( + await call("batch_decompile", { + addresses: ["0x1000"], + detail: "complete", + }), + ); + expect(batch.limitations.some((item) => item.includes(caveat))).toBe( + jobs > 1, + ); + const dossier = parseEvidence( + await call("analyze_function", { procedure: "main", detail: "summary" }), + ); + const summary = dossier.normalized_result as { parent_evidence_id: string }; + const view = parseEvidence( + await call("inspect_analysis_view", { + source: { + kind: "retained-evidence", + evidence_id: summary.parent_evidence_id, + }, + view: { kind: "native", facet: "pseudocode" }, + }), + ); + expect(view.limitations.some((item) => item.includes(caveat))).toBe( + jobs > 1, + ); + await call("close_binary", {}); + }); +} diff --git a/tests/boundary/providers/kuna/kunaProvider.test.ts b/tests/boundary/providers/kuna/kunaProvider.test.ts index 2b94145d65..6ab5eb1d81 100644 --- a/tests/boundary/providers/kuna/kunaProvider.test.ts +++ b/tests/boundary/providers/kuna/kunaProvider.test.ts @@ -235,6 +235,39 @@ it("lists the selected document without starting target analysis", async () => { } }); +it("qualifies only parallel project pseudocode, including dossier results", async () => { + const { open } = await session( + "rea-kuna-parallel-context-", + { delayMs: 300 }, + { REA_KUNA_JOBS: "2" }, + ); + const client = open(); + const caveat = + "Parallel Kuna project export does not propagate callee-first prototypes"; + const cold = await run(client, "procedure_pseudo_code", { + procedure: "main", + }); + expect(cold.rawResult).toMatchObject({ source: { kind: "single-function" } }); + expect(cold.limitations.join("\n")).not.toContain(caveat); + await run(client, "procedure_assembly", { procedure: "main" }); + const warm = await run(client, "procedure_pseudo_code", { + procedure: "main", + }); + expect(warm.rawResult).toMatchObject({ source: { kind: "project-export" } }); + expect(warm.limitations.join("\n")).toContain(caveat); + const dossier = await run(client, "analyze_function", { procedure: "main" }); + expect(dossier.limitations.join("\n")).toContain(caveat); + expect( + (dossier.result as { limitations: string[] }).limitations.join("\n"), + ).toContain(caveat); + const missing = await run(client, "procedure_pseudo_code", { + procedure: "broken", + }); + expect(missing.result).toBeNull(); + expect(missing.limitations.join("\n")).not.toContain(caveat); + await client.close(); +}); + it("answers a cold request from one function, then serves the finished export", async () => { const { kuna, cacheRoot, open } = await session("rea-kuna-cold-", { delayMs: 300, From 2d11fd92d233f65dd0f3904583e09078a5a3342c Mon Sep 17 00:00:00 2001 From: ShuaixinHuang <1836656514@qq.com> Date: Tue, 3 Nov 2026 08:54:39 +0800 Subject: [PATCH 03/14] fix(javascript): reconcile Node targets with entry assets (#8670) * fix(javascript): reconcile Node targets with entry assets * test(inspector): read reconciled evidence payload * Update tests/boundary/inspector/javascriptRuntimeNodeTarget.test.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * test(inspector): read the workflow evidence payload * Update tests/boundary/inspector/javascriptRuntimeNodeTarget.test.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * test(javascript): restore Evidence workflow result access --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- docs/javascript-runtime-reconciliation.md | 4 + .../javascript/javascriptRuntimeLoadState.ts | 5 +- .../javascriptRuntimeReconciliationRuntime.ts | 5 +- ...ascriptNodeTargetReconciliationCli.test.ts | 52 +++++++++++++ .../javascriptRuntimeNodeTarget.test.ts | 37 ++++++++++ tests/fixtures/nodeRuntimeReconciliation.ts | 73 +++++++++++++++++++ 6 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 tests/acceptance/applications/javascriptNodeTargetReconciliationCli.test.ts create mode 100644 tests/boundary/inspector/javascriptRuntimeNodeTarget.test.ts create mode 100644 tests/fixtures/nodeRuntimeReconciliation.ts diff --git a/docs/javascript-runtime-reconciliation.md b/docs/javascript-runtime-reconciliation.md index 85bfbff428..16b8fdc1e0 100644 --- a/docs/javascript-runtime-reconciliation.md +++ b/docs/javascript-runtime-reconciliation.md @@ -93,6 +93,10 @@ coverage. It does not print captured source text. Runtime targets, frames, scripts, and workers become capture-scoped graph nodes. REA evaluates compatible static candidates conservatively: +V8 Inspector targets whose protocol type is `node` use JavaScript asset/module +candidates for their verified entry location. A target-only correspondence does +not mark that script loaded; load-state evidence requires a script observation. + 1. A captured-source SHA-256 plus one matching mapped location yields a content-and-location match. 2. When no static candidate exists at a mapped location, one unique exact diff --git a/src/domain/javascript/javascriptRuntimeLoadState.ts b/src/domain/javascript/javascriptRuntimeLoadState.ts index 275de19d37..e49d60c8f7 100644 --- a/src/domain/javascript/javascriptRuntimeLoadState.ts +++ b/src/domain/javascript/javascriptRuntimeLoadState.ts @@ -57,7 +57,10 @@ const classifyLayer = ( const direct = new Map(); const ambiguous = new Set(); const incomplete = new Set(); - for (const match of matches) + // A target's entry location establishes correspondence, not script presence. + for (const match of matches.filter( + ({ entity_kind }) => entity_kind === "script", + )) if ( match.status === "matched" && match.static_layer_id === layer.layerId && diff --git a/src/domain/javascript/javascriptRuntimeReconciliationRuntime.ts b/src/domain/javascript/javascriptRuntimeReconciliationRuntime.ts index b0be2de5bd..613d47175c 100644 --- a/src/domain/javascript/javascriptRuntimeReconciliationRuntime.ts +++ b/src/domain/javascript/javascriptRuntimeReconciliationRuntime.ts @@ -94,7 +94,10 @@ const targetEntity = ( frameKey: null, scriptKey: null, location, - staticKind: "renderer", + staticKind: + capture.kind === "v8-inspector" && target.type === "node" + ? "javascript" + : "renderer", sourceSha256: null, label: target.title, properties: { diff --git a/tests/acceptance/applications/javascriptNodeTargetReconciliationCli.test.ts b/tests/acceptance/applications/javascriptNodeTargetReconciliationCli.test.ts new file mode 100644 index 0000000000..357bc04959 --- /dev/null +++ b/tests/acceptance/applications/javascriptNodeTargetReconciliationCli.test.ts @@ -0,0 +1,52 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { javascriptRuntimeReconciliationResultSchema } from "../../../src/domain/javascript/javascriptRuntimeReconciliationSchemas.js"; +import { nodeReconciliationFixture } from "../../fixtures/nodeRuntimeReconciliation.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "reconciles synthetic Node target entry Evidence through %s", + async (surface, { cli }) => { + const input = nodeReconciliationFixture(false); + let document: unknown; + if (surface === "CLI") { + const root = await createTestTempDirectory( + "rea-node-reconciliation-cli-", + ); + const path = join(root, "input.json"); + await writeFile(path, JSON.stringify(input)); + const response = await cli.run({ + arguments: ["reconcile-javascript-runtime", path, "--format", "json"], + }); + expect(response.exitCode, response.stderr).toBe(0); + document = response.json; + } else { + const call = await cli.sharedMcpCall!(); + const response = await call("reconcile_javascript_runtime", input); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + document = response.structuredContent; + } + const result = javascriptRuntimeReconciliationResultSchema.parse( + (document as { normalized_result: unknown }).normalized_result, + ); + expect(result.reconciliations).toEqual([ + expect.objectContaining({ + entity_kind: "target", + status: "matched", + reason: "unique-location-match", + confidence: "medium", + candidate_static_count: 1, + }), + ]); + expect(result.summary).toMatchObject({ + runtime_scripts: 0, + static_loaded: 0, + static_not_observed: 0, + }); + expect( + result.graph.edges.filter(({ relation }) => relation === "observed_as"), + ).toHaveLength(1); + }, +); diff --git a/tests/boundary/inspector/javascriptRuntimeNodeTarget.test.ts b/tests/boundary/inspector/javascriptRuntimeNodeTarget.test.ts new file mode 100644 index 0000000000..c56840195b --- /dev/null +++ b/tests/boundary/inspector/javascriptRuntimeNodeTarget.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from "vitest"; +import { reconcileJavaScriptRuntimeEvidence } from "../../../src/application/javascript/JavaScriptRuntimeReconciliationService.js"; +import { javascriptRuntimeReconciliationResultSchema } from "../../../src/domain/javascript/javascriptRuntimeReconciliationSchemas.js"; +import { nodeReconciliationFixture } from "../../fixtures/nodeRuntimeReconciliation.js"; + +test.each([false, true])( + "matches a synthetic Node entry target without inventing script presence: %s", + (scriptObserved) => { + const reconciled = reconcileJavaScriptRuntimeEvidence( + nodeReconciliationFixture(scriptObserved), + ); + if (!reconciled.ok) throw reconciled.error; + const result = javascriptRuntimeReconciliationResultSchema.parse( + reconciled.value.evidence.normalized_result, + ); + const target = result.reconciliations.find( + ({ entity_kind }) => entity_kind === "target", + ); + expect(target).toMatchObject({ + status: "matched", + basis: "artifact-path", + confidence: "medium", + reason: "unique-location-match", + candidate_static_count: 1, + }); + expect( + result.graph.nodes.find( + ({ node_id }) => node_id === target?.static_node_id, + )?.kind, + ).toBe("javascript-asset"); + expect(result.summary).toMatchObject({ + runtime_scripts: scriptObserved ? 1 : 0, + static_loaded: scriptObserved ? 1 : 0, + static_not_observed: 0, + }); + }, +); diff --git a/tests/fixtures/nodeRuntimeReconciliation.ts b/tests/fixtures/nodeRuntimeReconciliation.ts new file mode 100644 index 0000000000..cf6e75ba23 --- /dev/null +++ b/tests/fixtures/nodeRuntimeReconciliation.ts @@ -0,0 +1,73 @@ +import { createJavaScriptRuntimeObservationEvidence } from "../../src/application/javascript/JavaScriptRuntimeObservationEvidence.js"; +import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../../src/contracts/javascript/javascriptRuntimeReconciliationExample.js"; +import { javascriptRuntimeObservationSchema } from "../../src/domain/javascript/javascriptRuntimeObservation.js"; +import { V8_INSPECTOR_PROVIDER_IDENTITY } from "../../src/inspector/providerIdentity.js"; + +/** Synthetic, schema-validated capture; this is not a live Inspector observation. */ +export const nodeReconciliationFixture = (scriptObserved: boolean) => { + const location = { + kind: "file", + file_path: "/Applications/Example.app/Contents/Resources/app/renderer.js", + }; + const runtime = javascriptRuntimeObservationSchema.parse({ + runtime: { + product: "node.js/v24.22.1", + protocol_version: "1.3", + v8_version: null, + }, + target: { + target_id: "synthetic-node", + protocol_type: "node", + attached: null, + location, + runtime_kind: "unknown", + runtime_kind_authority: "not-declared", + }, + capture: { + observation_ms: 100, + events_observed: scriptObserved ? 1 : 0, + events_retained: scriptObserved ? 1 : 0, + events_dropped: 0, + metadata_bytes_retained: 0, + truncated: false, + truncation_reasons: [], + }, + scripts: { + items: scriptObserved + ? [ + { + script_key: `v8_script_${"4".repeat(64)}`, + location, + execution_context_key: null, + cdp_hash: null, + length: null, + is_module: null, + status: "observed-loaded", + }, + ] + : [], + observed_total: scriptObserved ? 1 : 0, + excluded: { unsupported_location: 0, invalid_protocol_value: 0 }, + }, + execution_contexts: [], + directly_observed: [], + unavailable_without_instrumentation: [], + unknowns: ["Synthetic bounded capture."], + limitations: ["Synthetic fixture, not a live runtime."], + }); + return { + static_layers: [{ analysis: JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE }], + runtime_observations: [ + createJavaScriptRuntimeObservationEvidence( + "observe_javascript_runtime", + { + inspector_endpoint: "http://127.0.0.1:9229", + target_id: "synthetic-node", + observation_ms: 100, + }, + runtime, + V8_INSPECTOR_PROVIDER_IDENTITY, + ), + ], + }; +}; From 510e83eadc5251ee5cc9469b894c7895ffe36ef2 Mon Sep 17 00:00:00 2001 From: ShuaixinHuang <1836656514@qq.com> Date: Tue, 3 Nov 2026 09:17:33 +0800 Subject: [PATCH 04/14] fix(firmware): recommend the operation-specific engine (#8668) --- docs/firmware-analysis.md | 3 + src/application/CapabilityInventory.test.ts | 56 +++++++++++++++++++ src/application/CapabilityInventory.ts | 13 ++++- src/cli/firmwareCommands.ts | 3 +- .../firmware/firmwareToolContracts.ts | 9 ++- .../firmware/firmwareToolRequirements.ts | 8 +++ src/firmware/FirmwareProvider.ts | 4 +- 7 files changed, 87 insertions(+), 9 deletions(-) diff --git a/docs/firmware-analysis.md b/docs/firmware-analysis.md index 28d70d4f27..de61db9794 100644 --- a/docs/firmware-analysis.md +++ b/docs/firmware-analysis.md @@ -26,6 +26,9 @@ REA does not install these tools, their dependencies or system packages. It checks the tool's `--version` banner when executing a request. Binwalk must print `binwalk ` and Unblob must print the version alone. Inspection and extraction have independent prerequisites; one does not require the other. +Capability discovery recommends `REA_BINWALK_COMMAND` for missing inspection +and `REA_UNBLOB_COMMAND` for missing extraction. Configuring the other engine +does not enable the unavailable operation. Both require util-linux `prlimit`, normally `/usr/bin/prlimit`; override its absolute path with `REA_FIRMWARE_PRLIMIT_COMMAND` if needed. `rea doctor` does not currently inspect firmware readiness. Engine selection/version checks run diff --git a/src/application/CapabilityInventory.test.ts b/src/application/CapabilityInventory.test.ts index 089685cd33..8425bd1ab9 100644 --- a/src/application/CapabilityInventory.test.ts +++ b/src/application/CapabilityInventory.test.ts @@ -880,6 +880,62 @@ describe("trace_feature requirements", () => { }); }); +describe("firmware operation recovery", () => { + it.each([ + [false, false], + [true, false], + [false, true], + [true, true], + ])( + "keeps inspection=%s and extraction=%s independent", + (inspection, extraction) => { + const inventory = buildCapabilityInventory(status(), { + processCaptureEnabled: false, + firmwareInspectionEnabled: inspection, + firmwareExtractionEnabled: extraction, + }); + for (const [name, enabled, command, otherCommand] of [ + [ + "inspect_firmware_regions", + inspection, + "REA_BINWALK_COMMAND", + "REA_UNBLOB_COMMAND", + ], + [ + "extract_firmware", + extraction, + "REA_UNBLOB_COMMAND", + "REA_BINWALK_COMMAND", + ], + ] as const) { + const tool = inventory.find((candidate) => candidate.name === name); + expect(tool).toMatchObject({ + available: enabled, + reason: enabled ? "available" : "provider_missing", + }); + if (enabled) { + expect(tool?.remediation).toBeNull(); + } else { + expect(tool?.remediation).toContain(command); + expect(tool?.remediation).not.toContain(otherCommand); + expect(tool?.remediation).toContain("prlimit"); + expect(tool?.remediation).toContain("procps ps"); + expect(tool?.remediation?.includes("external extractor")).toBe( + name === "extract_firmware", + ); + } + } + const byName = (left: ToolAvailability, right: ToolAvailability) => + left.name.localeCompare(right.name); + expect( + expandToolAvailability(compactToolAvailability(inventory)).toSorted( + byName, + ), + ).toEqual(inventory.toSorted(byName)); + }, + ); +}); + const HOST_RESTRICTED_TOOLS = [ ["inspect_packet_capture", "packetCapture", "packetCaptureEnabled"], ["inspect_packet_fields", "packetCapture", "packetCaptureEnabled"], diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index c2cb4642c3..b1316b5d6f 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -1,6 +1,9 @@ import { PCAP_CAPTURE_UNSUPPORTED_HOST_REASON } from "../domain/pcap/pcapAnalysis.js"; import { DEFAULT_CALL_RELATIONSHIP_DIRECTION } from "../contracts/enhancedInputs.js"; -import { FIRMWARE_TOOL_REQUIREMENTS } from "../contracts/firmware/firmwareToolRequirements.js"; +import { + FIRMWARE_OPERATION_ENGINES, + FIRMWARE_TOOL_REQUIREMENTS, +} from "../contracts/firmware/firmwareToolRequirements.js"; import { CALL_RELATIONSHIP_TRAVERSALS } from "./CallRelationshipTraversal.js"; import { NATIVE_DISPATCH_METADATA_SOURCES } from "./native/NativeDispatchMetadataInspection.js"; import { NATIVE_UI_ACTION_OPERATIONS } from "./native/NativeUiActionTrace.js"; @@ -595,15 +598,19 @@ const workflowAvailabilityFor = ({ "On Linux x64, set absolute REA_PWNTOOLS_PYTHON to caller-supplied Python with pwntools 4.15.0. No active binary target is required.", ); if (kind === "firmware-provider") { - const { binwalk, unblob } = FIRMWARE_TOOL_REQUIREMENTS; const inspection = name === "inspect_firmware_regions"; + const operation = inspection + ? "inspect_firmware_regions" + : "extract_firmware"; + const requirement = + FIRMWARE_TOOL_REQUIREMENTS[FIRMWARE_OPERATION_ENGINES[operation]]; return hostRestrictedAvailability( policy, inspection ? "firmwareInspection" : "firmwareExtraction", inspection ? policy.firmwareInspectionEnabled : policy.firmwareExtractionEnabled, - `On Linux, provide an absolute ${binwalk.commandEnvironment} (${binwalk.name} ${binwalk.line}, verified with ${binwalk.version}) or ${unblob.commandEnvironment} (${unblob.name} ${unblob.line}, verified with ${unblob.version}) path and util-linux prlimit. Extraction also requires the selected format’s external extractor.`, + `On Linux, provide an absolute ${requirement.commandEnvironment} (${requirement.requirement}) path, util-linux prlimit and procps ps.${inspection ? "" : " Extraction also requires the selected format’s external extractor."}`, ); } if (kind === "android-provider") diff --git a/src/cli/firmwareCommands.ts b/src/cli/firmwareCommands.ts index 43223a58cd..564b563c3e 100644 --- a/src/cli/firmwareCommands.ts +++ b/src/cli/firmwareCommands.ts @@ -1,3 +1,4 @@ +import { FIRMWARE_OPERATION_ENGINES } from "../contracts/firmware/firmwareToolRequirements.js"; import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { z } from "incur"; import { FirmwareAnalysisService } from "../application/firmware/FirmwareAnalysisService.js"; @@ -36,7 +37,7 @@ export const registerFirmwareCommands = ( callerPath, () => service.execute(operation, input, { signal }), service, - operation === "inspect_firmware_regions" ? "binwalk" : "unblob", + FIRMWARE_OPERATION_ENGINES[operation], operation, signal, ).then(projectCliAnalysisResult), diff --git a/src/contracts/firmware/firmwareToolContracts.ts b/src/contracts/firmware/firmwareToolContracts.ts index 2bb8e19980..c210212271 100644 --- a/src/contracts/firmware/firmwareToolContracts.ts +++ b/src/contracts/firmware/firmwareToolContracts.ts @@ -5,7 +5,10 @@ import { import type { ToolContract } from "../toolContractTypes.js"; import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js"; import { toolContractMetadata } from "../toolEffects.js"; -import { FIRMWARE_TOOL_REQUIREMENTS } from "./firmwareToolRequirements.js"; +import { + FIRMWARE_OPERATION_ENGINES, + FIRMWARE_TOOL_REQUIREMENTS, +} from "./firmwareToolRequirements.js"; /** Explicit local firmware observations and extraction, backed by caller-supplied tools. */ export const FIRMWARE_TOOL_CONTRACTS = [ @@ -13,7 +16,7 @@ export const FIRMWARE_TOOL_CONTRACTS = [ name: "inspect_firmware_regions", ...toolContractMetadata("inspect_firmware_regions"), kind: "firmware-provider", - description: `Inspect signature candidates in a local firmware file with ${FIRMWARE_TOOL_REQUIREMENTS.binwalk.requirement}. Returns offsets, reported lengths, descriptions and raw confidence values. Length validation and runtime addresses remain unknown. No extraction or target execution. Linux, caller-supplied tool, util-linux prlimit and procps ps required; 128 MiB input limit, one worker. Adjust analysis_timeout_ms for the analysis wall-clock budget (default 120000); fixed per-process limits are unchanged.`, + description: `Inspect signature candidates in a local firmware file with ${FIRMWARE_TOOL_REQUIREMENTS[FIRMWARE_OPERATION_ENGINES.inspect_firmware_regions].requirement}. Returns offsets, reported lengths, descriptions and raw confidence values. Length validation and runtime addresses remain unknown. No extraction or target execution. Linux, caller-supplied tool, util-linux prlimit and procps ps required; 128 MiB input limit, one worker. Adjust analysis_timeout_ms for the analysis wall-clock budget (default 120000); fixed per-process limits are unchanged.`, inputSchema: firmwareInputSchemas.inspect_firmware_regions, outputSchema: evidenceResultOf( firmwareResultSchemas.inspect_firmware_regions, @@ -29,7 +32,7 @@ export const FIRMWARE_TOOL_CONTRACTS = [ name: "extract_firmware", ...toolContractMetadata("extract_firmware"), kind: "firmware-provider", - description: `Extract caller-selected firmware bytes with ${FIRMWARE_TOOL_REQUIREMENTS.unblob.requirement} into an absent output directory. Returns verified child paths/digests, task derivations, unknown chunks and uncovered selected input ranges, extraction diagnostics and explicit extraction_gaps rows naming unextracted regions, missing extractor dependencies and a continuation_id referencing shared extraction_continuations recovery instructions. Preserves the default sandbox; publishes regular files only. Linux, caller-supplied tool, format-specific extractors, util-linux prlimit and procps ps required. No mounts or extracted-code execution. Select a returned native file with open_binary for Ghidra analysis; decompressed offsets are not original-file addresses.`, + description: `Extract caller-selected firmware bytes with ${FIRMWARE_TOOL_REQUIREMENTS[FIRMWARE_OPERATION_ENGINES.extract_firmware].requirement} into an absent output directory. Returns verified child paths/digests, task derivations, unknown chunks and uncovered selected input ranges, extraction diagnostics and explicit extraction_gaps rows naming unextracted regions, missing extractor dependencies and a continuation_id referencing shared extraction_continuations recovery instructions. Preserves the default sandbox; publishes regular files only. Linux, caller-supplied tool, format-specific extractors, util-linux prlimit and procps ps required. No mounts or extracted-code execution. Select a returned native file with open_binary for Ghidra analysis; decompressed offsets are not original-file addresses.`, inputSchema: firmwareInputSchemas.extract_firmware, outputSchema: evidenceResultOf(firmwareResultSchemas.extract_firmware), examples: [ diff --git a/src/contracts/firmware/firmwareToolRequirements.ts b/src/contracts/firmware/firmwareToolRequirements.ts index fb0816fe97..824a43816a 100644 --- a/src/contracts/firmware/firmwareToolRequirements.ts +++ b/src/contracts/firmware/firmwareToolRequirements.ts @@ -1,3 +1,5 @@ +import type { FirmwareOperation } from "../../domain/firmware/firmwareAnalysis.js"; + /** Derive public support facts from one audited, stable release number. */ const defineToolRequirement = (release: { readonly name: string; @@ -39,3 +41,9 @@ export const FIRMWARE_TOOL_REQUIREMENTS = { /** Caller-supplied firmware engine selected by the operation. */ export type FirmwareEngineName = keyof typeof FIRMWARE_TOOL_REQUIREMENTS; + +/** The engine required by each existing firmware operation, shared by discovery and execution. */ +export const FIRMWARE_OPERATION_ENGINES = { + inspect_firmware_regions: "binwalk", + extract_firmware: "unblob", +} as const satisfies Readonly>; diff --git a/src/firmware/FirmwareProvider.ts b/src/firmware/FirmwareProvider.ts index 377095073d..c0653816ab 100644 --- a/src/firmware/FirmwareProvider.ts +++ b/src/firmware/FirmwareProvider.ts @@ -1,3 +1,4 @@ +import { FIRMWARE_OPERATION_ENGINES } from "../contracts/firmware/firmwareToolRequirements.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { assertArtifactCommandPath } from "../process/ArtifactCommandPath.js"; import { join } from "node:path"; @@ -332,8 +333,7 @@ export class FirmwareProvider implements FirmwareAnalysisPort { const environment = snapshotEnvironment(this.environment); let root: PrivateRuntimeRoot | undefined; let rootLease: AnalysisCleanupLease | undefined; - const engineName = - request.operation === "inspect_firmware_regions" ? "binwalk" : "unblob"; + const engineName = FIRMWARE_OPERATION_ENGINES[request.operation]; const withCaller = (phase: FirmwareDeadline) => options?.signal === undefined ? phase.signal From eef8f44003e6bf781baf4f885f84d03b31ea6c3a Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:33:41 +0800 Subject: [PATCH 05/14] feat(website): publish agent discovery and Markdown responses --- .github/workflows/website-check.yml | 4 + .github/workflows/website-pages.yml | 44 +++++- scripts/ci/scopes.mjs | 4 +- scripts/prepare-website.py | 3 + scripts/test-website-agents.py | 109 +++++++++++++++ scripts/website.py | 3 +- scripts/website_agents.py | 116 ++++++++++++++++ .../boundary/filesystem/ciChangeScope.test.ts | 9 +- website/README.md | 58 +++++++- website/public/_headers | 11 ++ website/requirements.txt | 3 + website/worker.mjs | 103 ++++++++++++++ website/worker.test.mjs | 127 ++++++++++++++++++ website/wrangler.toml | 5 +- 14 files changed, 591 insertions(+), 8 deletions(-) create mode 100644 scripts/test-website-agents.py create mode 100644 scripts/website_agents.py create mode 100644 website/public/_headers create mode 100644 website/worker.mjs create mode 100644 website/worker.test.mjs diff --git a/.github/workflows/website-check.yml b/.github/workflows/website-check.yml index dc17b4b969..a7106ac7c2 100644 --- a/.github/workflows/website-check.yml +++ b/.github/workflows/website-check.yml @@ -21,6 +21,10 @@ jobs: python-version: "3.12" cache: pip cache-dependency-path: website/requirements.txt + - name: Set up Node.js for the website Worker checks + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: .nvmrc - name: Install sharing-image dependencies run: | sudo apt-get update diff --git a/.github/workflows/website-pages.yml b/.github/workflows/website-pages.yml index 491b028a65..9bfa7b630e 100644 --- a/.github/workflows/website-pages.yml +++ b/.github/workflows/website-pages.yml @@ -54,6 +54,10 @@ jobs: python-version: "3.12" cache: pip cache-dependency-path: website/requirements.txt + - name: Set up Node.js for the website Worker checks + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: .nvmrc - name: Install sharing-image dependencies run: | sudo apt-get update @@ -75,10 +79,21 @@ jobs: PY - name: Configure Pages uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0 + # upload-pages-artifact excludes dot directories, including .well-known. + - name: Package the website including agent discovery + run: | + tar --dereference --hard-dereference --directory website/_site \ + --exclude=.git --exclude=.github -cf "$RUNNER_TEMP/artifact.tar" . + tar -tf "$RUNNER_TEMP/artifact.tar" \ + ./.well-known/ard.json ./.well-known/ai-catalog.json \ + ./.well-known/agent-skills/index.json > /dev/null - name: Upload the website - uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - path: website/_site + name: github-pages + path: ${{ runner.temp }}/artifact.tar + retention-days: 1 + if-no-files-found: error deploy: needs: prepare @@ -130,12 +145,13 @@ jobs: PUBLISH_TARGET: ${{ inputs.target }} run: | python3 -u - <<'PY' + import hashlib import json import os import sys import time from urllib.error import URLError - from urllib.parse import urljoin + from urllib.parse import urljoin, urlsplit from urllib.request import Request, urlopen expected = os.environ["GITHUB_SHA"] @@ -172,6 +188,28 @@ jobs: time.sleep(5) if pending: sys.exit("Could not verify published revision on: " + ", ".join(pending)) + for name, base in hosts.items(): + with urlopen(urljoin(base, ".well-known/agent-skills/index.json"), timeout=15) as response: + skill, = json.load(response)["skills"] + # Fetch from each selected host, including the /rea/ mirror. + artifact_url = urljoin(base, urlsplit(skill["url"]).path.lstrip("/")) + with urlopen(artifact_url, timeout=15) as response: + digest = "sha256:" + hashlib.sha256(response.read()).hexdigest() + if digest != skill["digest"]: + sys.exit(name + " serves a skill archive with the wrong digest") + for manifest in ("ard.json", "ai-catalog.json"): + with urlopen(urljoin(base, ".well-known/" + manifest), timeout=15) as response: + if not json.load(response).get("entries"): + sys.exit(name + " serves an empty discovery manifest") + if name == "Cloudflare": + request = Request(base, headers={"Accept": "text/markdown"}) + with urlopen(request, timeout=15) as response: + vary = {value.strip().lower() for value in response.headers.get("Vary", "").split(",")} + if response.headers.get_content_type() != "text/markdown" or "accept" not in vary: + sys.exit("Cloudflare did not negotiate Markdown with Vary: Accept") + if 'rel="ard"' not in response.headers.get("Link", ""): + sys.exit("Cloudflare is missing its discovery Link header") + print(name + " serves the discovery manifests and verified skill archive") with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as summary: links = " and ".join(f"[{name}]({url})" for name, url in hosts.items()) summary.write(f"Published commit `{expected}` to {links}.\n") diff --git a/scripts/ci/scopes.mjs b/scripts/ci/scopes.mjs index 7f5e4d3839..03bcf518d3 100644 --- a/scripts/ci/scopes.mjs +++ b/scripts/ci/scopes.mjs @@ -704,7 +704,9 @@ export const CI_LANES = { agentenv: sameJobs(PATH_SCOPES.agentenv, ["agentenv"]), nativeaot: sameJobs(PATH_SCOPES.nativeaot, ["nativeaot"]), ghidra_bridge: sameJobs(PATH_SCOPES.ghidra_bridge, ["ghidra_bridge"]), - website: sameJobs(PATH_SCOPES.website, ["website"]), + website: sameJobs(PATH_SCOPES.website, ["website"], { + earlyInputs: [".agents/skills/reverse-engineer-anything/**"], + }), }; // An input owner omitted from the registry must never silently lose a lane. for (const [name, paths] of Object.entries(PATH_SCOPES)) diff --git a/scripts/prepare-website.py b/scripts/prepare-website.py index cd284d3e47..33881a25e1 100644 --- a/scripts/prepare-website.py +++ b/scripts/prepare-website.py @@ -7,6 +7,7 @@ import xml.etree.ElementTree as ET from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo from website import PageMetadata, page_url +from website_agents import prepare_discovery, prepare_markdown EXAMPLE_FILES = ( @@ -81,6 +82,8 @@ def prepare_assets(site): prepare_sharing_image(site) prepare_sitemap(site) prepare_notes_example(site) + prepare_discovery(Path(__file__).resolve().parent.parent, site) + prepare_markdown(site) if __name__ == "__main__": diff --git a/scripts/test-website-agents.py b/scripts/test-website-agents.py new file mode 100644 index 0000000000..d5349e1b1a --- /dev/null +++ b/scripts/test-website-agents.py @@ -0,0 +1,109 @@ +"""Check the public build artifacts and Markdown content contract.""" + +import hashlib +import json +from pathlib import Path +import tempfile +import unittest +from zipfile import ZipFile + +from bs4 import BeautifulSoup +import yaml + +from website_agents import prepare_discovery, prepare_markdown + + +ROOT = Path(__file__).resolve().parent.parent + + +class AgentPublishingChecks(unittest.TestCase): + def test_published_skill_digest_covers_every_reference_and_catalogs_resolve(self): + site = ROOT / "website/_site" + index = json.loads((site / ".well-known/agent-skills/index.json").read_text()) + self.assertEqual(index["$schema"], "https://schemas.agentskills.io/discovery/0.2.0/schema.json") + skill, = index["skills"] + self.assertEqual(skill["name"], "reverse-engineer-anything") + self.assertEqual(skill["type"], "archive") + artifact = site / skill["url"].removeprefix("https://rea.tools/") + self.assertEqual(skill["digest"], "sha256:" + hashlib.sha256(artifact.read_bytes()).hexdigest()) + source = ROOT / ".agents/skills/reverse-engineer-anything" + with ZipFile(artifact) as archive: + expected = {f"{source.name}/{p.relative_to(source).as_posix()}": p.read_bytes() + for p in source.rglob("*.md")} + self.assertEqual(set(archive.namelist()), set(expected)) + for name, data in expected.items(): + self.assertEqual(archive.read(name), data) + manifest = archive.read(f"{source.name}/SKILL.md").decode() + metadata = yaml.safe_load(manifest[4:].split("\n---\n", 1)[0]) + self.assertEqual(skill["description"], metadata["description"]) + catalog = json.loads((site / ".well-known/ai-catalog.json").read_text()) + self.assertEqual(catalog["specVersion"], "1.0") + entry, = catalog["entries"] + self.assertEqual(entry["identifier"], "urn:air:rea.tools:skill:reverse-engineer-anything") + self.assertEqual(entry["url"], skill["url"]) + self.assertNotIn("data", entry) + ard = json.loads((site / ".well-known/ard.json").read_text()) + resource, = ard["entries"] + self.assertEqual(resource["url"], skill["url"]) + self.assertTrue(2 <= len(resource["representativeQueries"]) <= 5) + + def test_reference_changes_invalidate_digest_and_yaml_scalars_are_parsed(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / ".agents/skills/reverse-engineer-anything" + source.mkdir(parents=True) + (source / "SKILL.md").write_text( + '---\nname: reverse-engineer-anything\ndescription: >-\n Inspect programs:\n preserve evidence.\n---\n# REA\n' + ) + (source / "references").mkdir() + reference = source / "references/evidence.md" + reference.write_text("First revision") + site = root / "site" + def build(): + prepare_discovery(root, site) + return json.loads((site / ".well-known/agent-skills/index.json").read_text())["skills"][0] + before = build() + self.assertEqual(before["description"], "Inspect programs: preserve evidence.") + self.assertEqual(build(), before) + reference.write_text("Second revision") + self.assertNotEqual(build()["digest"], before["digest"]) + + def test_markdown_preserves_content_and_resources_without_publishing_excluded_pages(self): + with tempfile.TemporaryDirectory() as directory: + site = Path(directory) + page = site / "zh-CN/guides/test/index.html" + page.parent.mkdir(parents=True) + page.write_text('''REA: test +
+

Inspect a program

+
  1. Readinput.exeKeep evidence.
+

Another guide

+
const value = a < b ? "x_y" : "&";\n  keepIndent();
+
Evidence

Hidden evidence remains available.

+ Investigation diagram +
''') + fixture = site / "examples/notes-web/index.html" + fixture.parent.mkdir(parents=True) + fixture.write_text("
Target application
") + fallback = site / "zh-CN/fallback/index.html" + fallback.parent.mkdir(parents=True) + fallback.write_text('
English fallback notice.
') + prepare_markdown(site) + markdown = page.with_suffix(".md").read_text() + self.assertIn('# Inspect a program', markdown) + self.assertIn('const value = a < b ? "x_y" : "&";\n keepIndent();', markdown) + self.assertIn("Hidden evidence remains available.", markdown) + self.assertIn("Shortened display paths.", markdown) + self.assertNotIn("Read`input.exe`", markdown) + self.assertIn("https://rea.tools/zh-CN/guides/other/?q=a%2Fb#code", markdown) + self.assertIn("https://rea.tools/assets/diagram.svg", markdown) + for text in ("Outside navigation", "Contents navigation", "Copy code", "doNotPublish"): + self.assertNotIn(text, markdown) + self.assertFalse(fixture.with_suffix(".md").exists()) + self.assertFalse(fallback.with_suffix(".md").exists()) + html = BeautifulSoup(page.read_text(), "html.parser") + self.assertEqual(html.find("link", rel="alternate")["href"], "https://rea.tools/zh-CN/guides/test/index.md") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/website.py b/scripts/website.py index 4891347ff9..b6b3a39f19 100644 --- a/scripts/website.py +++ b/scripts/website.py @@ -327,8 +327,9 @@ def main(): refresh_contributors(root) elif args.command == "check": sync_pages(root, check=True) - for name in ("prepare-website.py", "verify-website.py", "test-website.py"): + for name in ("prepare-website.py", "verify-website.py", "test-website.py", "test-website-agents.py"): run_script(root, name) + subprocess.run(["node", "--test", "website/worker.test.mjs"], cwd=root, check=True) elif args.command == "links": run_script(root, "audit-website-links.py", ["--report", args.report] if args.report else []) else: diff --git a/scripts/website_agents.py b/scripts/website_agents.py new file mode 100644 index 0000000000..74a3e0a5d5 --- /dev/null +++ b/scripts/website_agents.py @@ -0,0 +1,116 @@ +"""Build public agent discovery and Markdown from their maintained sources.""" + +import hashlib +import json +from urllib.parse import urljoin +from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo + +from bs4 import BeautifulSoup +from markdownify import MarkdownConverter +import yaml + +from website import NOTES_FIXTURES, PageMetadata, SITE_ORIGIN, page_url + + +def write_json(path, value): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def prepare_discovery(root, site): + """Publish the complete skill, including its relative reference documents.""" + source = root / ".agents/skills/reverse-engineer-anything" + skill = (source / "SKILL.md").read_text(encoding="utf-8") + if not skill.startswith("---\n") or "\n---\n" not in skill[4:]: + raise ValueError("Public REA skill needs YAML frontmatter") + metadata = yaml.safe_load(skill[4:].split("\n---\n", 1)[0]) + if not isinstance(metadata, dict): + raise ValueError("Public REA skill needs YAML metadata") + name, description = metadata["name"], metadata["description"] + if name != source.name or not isinstance(description, str) or not description.strip(): + raise ValueError("Invalid public REA skill metadata") + discovery = site / ".well-known/agent-skills" + discovery.mkdir(parents=True, exist_ok=True) + artifact = discovery / f"{name}.zip" + with ZipFile(artifact, "w") as archive: + for path in [source / "SKILL.md", *sorted(source.glob("references/**/*.md"))]: + relative = path.relative_to(source) + entry = ZipInfo(f"{name}/{relative.as_posix()}", (1980, 1, 1, 0, 0, 0)) + entry.create_system = 3 + entry.external_attr = 0o100644 << 16 + archive.writestr(entry, path.read_bytes(), compress_type=ZIP_DEFLATED) + artifact_url = f"{SITE_ORIGIN}/.well-known/agent-skills/{artifact.name}" + write_json(discovery / "index.json", { + "$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json", + "skills": [{ + "name": name, "type": "archive", "description": description, + "url": artifact_url, + "digest": "sha256:" + hashlib.sha256(artifact.read_bytes()).hexdigest(), + }], + }) + entry = { + "identifier": f"urn:air:rea.tools:skill:{name}", + "displayName": "REA reverse engineering skill", + "description": description, + "type": "application/zip", + "url": artifact_url, + } + write_json(site / ".well-known/ai-catalog.json", { + "specVersion": "1.0", + "host": {"displayName": "REA", "documentationUrl": f"{SITE_ORIGIN}/get-started/"}, + "entries": [entry], + }) + write_json(site / ".well-known/ard.json", {"entries": [{ + **entry, + "representativeQueries": [ + "Explain how a feature works in a shipped executable using REA.", + "Trace an Electron application's behavior back to its packaged JavaScript.", + "Build an AgentEnv bundle from a web application capture.", + ], + }]}) + print("Prepared agent skill archive, discovery index, AI Catalog and ARD manifest.") + + +def prepare_markdown(site): + """Convert rendered reading pages, keeping code and resolving resource URLs.""" + converter = MarkdownConverter(heading_style="ATX", bullets="-", escape_underscores=False) + count = 0 + for path in sorted(site.rglob("*.html")): + if path.relative_to(site).as_posix() in NOTES_FIXTURES: + continue + source = path.read_text(encoding="utf-8") + metadata = PageMetadata() + metadata.feed(source) + # Drafts and English fallbacks keep their HTML indexing policy intact. + if metadata.noindex: + continue + soup = BeautifulSoup(source, "html.parser") + main = soup.find("main") + if main is None: + continue + # Preserve fallback notices and closed disclosures; remove interface chrome. + for node in main.select('script, style, nav, button, svg, .home-page-nav, [aria-hidden="true"]'): + if node.parent is not None: + node.decompose() + # These spans are separate lines in the shared flow component's CSS. + for node in main.select("span.flow-label, span.flow-node, span.flow-note"): + node.name = "div" + canonical = page_url(site, path) + for node in main.select("[href], [src]"): + for attribute in ("href", "src"): + if node.has_attr(attribute): + node[attribute] = urljoin(canonical, node[attribute]) + title = metadata.titles[0] if metadata.titles else "REA" + header = yaml.safe_dump({"title": title, "url": canonical}, allow_unicode=True, sort_keys=False) + markdown = converter.convert_soup(main).strip() + path.with_suffix(".md").write_text(f"---\n{header}---\n\n{markdown}\n", encoding="utf-8") + alternate = page_url(site, path.with_suffix(".md")) + links = ( + f'\n' + f'\n' + f'\n' + f'\n' + ) + path.write_text(source.replace("", links + "", 1), encoding="utf-8") + count += 1 + print(f"Prepared Markdown for {count} reading pages.") diff --git a/tests/boundary/filesystem/ciChangeScope.test.ts b/tests/boundary/filesystem/ciChangeScope.test.ts index 351223eb05..e5710daa25 100644 --- a/tests/boundary/filesystem/ciChangeScope.test.ts +++ b/tests/boundary/filesystem/ciChangeScope.test.ts @@ -879,7 +879,14 @@ it.each([ ["install.sh", { code: true, runtime: true, package: true, evm: false }], [ ".agents/skills/reverse-engineer-anything/SKILL.md", - { code: true, runtime: true, docs: true, package: false, evm: false }, + { + code: true, + runtime: true, + docs: true, + website: true, + package: false, + evm: false, + }, ], [ "scripts/verify-real-ghidra.mjs", diff --git a/website/README.md b/website/README.md index 47ea63ca14..d8cd2d5f41 100644 --- a/website/README.md +++ b/website/README.md @@ -486,10 +486,66 @@ The mask SVG is drawn from the seventh decoded mask. It selects zero-based position 21 and compares its character code with 55 (`7`). Keep the figure, solver constants and evidence notes aligned when changing this case. +## Agent discovery and Markdown + +`scripts/website_agents.py` runs during asset preparation, after translations. +It generates these public files in ignored `_site/`: + +- `.well-known/agent-skills/index.json`: the draft v0.2.0 skills discovery index. + Its archive contains the maintained + `.agents/skills/reverse-engineer-anything/SKILL.md` and Markdown references. + The name and description come from YAML frontmatter; SHA-256 covers the + complete, reproducible ZIP, so reference changes invalidate the digest too. +- `.well-known/ard.json`: an ARD manifest with representative analyst queries. +- `.well-known/ai-catalog.json`: an AI Catalog 1.0 document listing that same + downloadable skill. These advertise installation instructions for the local + tool, without claiming a hosted analysis endpoint. +- `index.md` beside each indexable rendered reading page, including translations. + Markdown retains code, evidence disclosures and image descriptions, with + absolute resource links. Drafts, labeled English fallbacks and target example + applications retain their HTML and indexing policy. + +The [skills discovery draft](https://github.com/cloudflare/agent-skills-discovery-rfc), +[ARD proposal](https://agenticresourcediscovery.org/spec/) and +[AI Catalog proposal](https://ai-catalog.io/) are evolving formats. ARD v0.91 +uses `ard.json` and `rel="ard"`; AI Catalog uses its separate `ai-catalog.json` +envelope. Both entries point to the same ZIP with the registered +`application/zip` media type. + +Cloudflare's Worker selects Markdown when an explicit `Accept: text/markdown` +has higher quality than HTML. HTML wins ties and remains the default. Successful +reading-page responses include `Vary: Accept` and an alternate Markdown Link; +Markdown uses `Content-Type: text/markdown; charset=utf-8`. Static `_headers` +advertises discovery resources, sets discovery CORS to `*`, and supplies MIME +types. Build-generated HTML `` elements also advertise these resources. + +GitHub Pages and the Python preview serve the same files and HTML links, with +direct Markdown URLs such as `guides/javascript/index.md`. They do not execute +the Worker or apply Cloudflare's `_headers`: content negotiation, custom MIME +overrides and discovery CORS headers are Cloudflare behavior. The publish +workflow packages `.well-known` explicitly because `upload-pages-artifact` +excludes dot directories by default. + +`python3 scripts/website.py check` includes artifact/content regressions and +Worker request-contract tests. To check the actual local Cloudflare runtime +after preparing assets, use the repository's pinned Node.js and Wrangler: + +```sh +npx wrangler@4.149.0 dev --cwd website --env production --local +curl -i -H 'Accept: text/markdown' http://localhost:8787/guides/javascript/ +curl -I http://localhost:8787/.well-known/agent-skills/index.json +``` + +The `production` environment here skips the preview build and uses the already +verified artifact; `--local` runs on your machine. Check HTML and Markdown, +GET and HEAD, discovery CORS, the downloaded ZIP digest, and unknown routes. +Production remains published only by the manual website workflow. + ## Cloudflare Workers `wrangler.toml` serves `website/_site/` through Workers Static Assets. There is -no Worker script or frontend framework. Before a preview or deployment, Wrangler +one small Worker (`worker.mjs`) for Markdown content negotiation and no frontend +framework. Before a preview or deployment, Wrangler runs the Python scripts to build the language editions, prepare the example ZIP and verify the website. Node.js and Python 3 must be available; the commands below use a pinned Wrangler version without adding it to REA's package dependencies. diff --git a/website/public/_headers b/website/public/_headers new file mode 100644 index 0000000000..2065dd5df3 --- /dev/null +++ b/website/public/_headers @@ -0,0 +1,11 @@ +/* + Link: ; rel="ard", ; rel="ai-catalog", ; rel="related"; type="application/json"; title="Agent skills index" + +/.well-known/* + Access-Control-Allow-Origin: * + +/.well-known/ai-catalog.json + Content-Type: application/ai-catalog+json + +/*.md + Content-Type: text/markdown; charset=utf-8 diff --git a/website/requirements.txt b/website/requirements.txt index e6099d25dc..67e1541cfd 100644 --- a/website/requirements.txt +++ b/website/requirements.txt @@ -2,3 +2,6 @@ CairoSVG==2.9.1 # Standard gettext PO/POT catalogs; no translation service is used by builds. polib==1.2.0 +# Build Markdown reading pages and parse the maintained skill's YAML metadata. +markdownify==1.2.3 +PyYAML==6.0.3 diff --git a/website/worker.mjs b/website/worker.mjs new file mode 100644 index 0000000000..b87bf8acfb --- /dev/null +++ b/website/worker.mjs @@ -0,0 +1,103 @@ +// Negotiate prebuilt Markdown while keeping the static asset service's routing. +function prefersMarkdown(accept) { + const ranges = (accept ?? "") + .toLowerCase() + .split(",") + .map((range) => { + const [type, ...parameters] = range.trim().split(";"); + const quality = parameters + .map((value) => value.trim()) + .find((value) => value.startsWith("q=")); + const q = quality === undefined ? 1 : Number(quality.slice(2)); + return { + type: type.trim(), + q: Number.isFinite(q) && q >= 0 && q <= 1 ? q : 0, + }; + }); + const quality = (type) => { + for (const match of [type, "text/*", "*/*"]) { + const matches = ranges.filter((range) => range.type === match); + if (matches.length) return Math.max(...matches.map((range) => range.q)); + } + return 0; + }; + // Wildcards alone never opt a browser into Markdown; HTML wins ties. + return ( + ranges.some((range) => range.type === "text/markdown") && + quality("text/markdown") > 0 && + quality("text/markdown") > quality("text/html") + ); +} + +function advertiseMarkdown(response, path) { + const vary = new Set( + (response.headers.get("Vary") ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + ); + if (![...vary].some((s) => s.toLowerCase() === "accept" || s === "*")) + vary.add("Accept"); + response.headers.set("Vary", [...vary].join(", ")); + response.headers.append( + "Link", + `<${path}>; rel="alternate"; type="text/markdown"`, + ); + return response; +} + +export default { + async fetch(request, env) { + const useMarkdown = prefersMarkdown(request.headers.get("Accept")); + // HTML validators describe a different representation. Probe the route first, + // then let the asset service evaluate validators/ranges against the Markdown. + const probe = new Request(request); + if (useMarkdown) { + for (const name of [ + "If-None-Match", + "If-Modified-Since", + "If-Match", + "If-Unmodified-Since", + "If-Range", + "Range", + ]) + probe.headers.delete(name); + } + const original = await env.ASSETS.fetch(probe); + if ( + ![200, 206, 304].includes(original.status) || + !["GET", "HEAD"].includes(request.method) || + !original.headers.get("Content-Type")?.startsWith("text/html") + ) + return original; + + const url = new URL(request.url); + if (!url.pathname.endsWith("/") && !url.pathname.endsWith(".html")) + return original; + const markdown = new URL(url); + markdown.pathname = url.pathname.endsWith("/") + ? `${url.pathname}index.md` + : url.pathname.slice(0, -5) + ".md"; + const candidate = await env.ASSETS.fetch( + useMarkdown + ? new Request(markdown, request) + : new Request(markdown, { method: "HEAD" }), + ); + if (!candidate.ok && ![304, 412, 416].includes(candidate.status)) { + await candidate.body?.cancel(); + return original; + } + const response = new Response( + useMarkdown ? candidate.body : original.body, + useMarkdown ? candidate : original, + ); + if (useMarkdown) { + await original.body?.cancel(); + if (candidate.status < 400) + response.headers.set("Content-Type", "text/markdown; charset=utf-8"); + } else { + await candidate.body?.cancel(); + } + return advertiseMarkdown(response, markdown.pathname); + }, +}; diff --git a/website/worker.test.mjs b/website/worker.test.mjs new file mode 100644 index 0000000000..490e8205a9 --- /dev/null +++ b/website/worker.test.mjs @@ -0,0 +1,127 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import worker from "./worker.mjs"; + +function assets({ markdownStatus = 200 } = {}) { + return { + ASSETS: { + async fetch(request) { + const url = new URL(request.url); + const markdown = url.pathname.endsWith("index.md"); + if (url.pathname === "/missing/") + return new Response("Missing", { status: 404 }); + if (url.pathname === "/guide") + return Response.redirect(`${url.origin}/guide/`, 307); + const headers = { + "Content-Type": markdown ? "text/plain" : "text/html", + ETag: markdown ? '"markdown"' : '"html"', + Link: '; rel="ard"', + }; + const status = + request.headers.get("If-None-Match") === headers.ETag + ? 304 + : markdown + ? markdownStatus + : 200; + return new Response( + request.method === "HEAD" || status === 304 + ? null + : markdown + ? "# Guide" + : "

Guide

", + { headers, status }, + ); + }, + }, + }; +} + +test("HTML is the default and explicit Accept preferences select Markdown", async () => { + for (const [accept, markdown] of [ + [null, false], + ["*/*", false], + ["text/html", false], + ["text/markdown", true], + ["text/markdown;q=0", false], + ["text/markdown;q=0.5,text/html", false], + ["text/markdown,text/html;q=0.5", true], + ["text/markdown,text/html", false], + ["TEXT/MARKDOWN; q=1,text/*;q=0.5", true], + ["text/markdown;q=bad", false], + ["text/markdown;q=0,*/*;q=1", false], + ]) { + const response = await worker.fetch( + new Request("https://rea.tools/guide/", { + headers: accept === null ? {} : { Accept: accept }, + }), + assets(), + ); + assert.equal( + response.headers.get("Content-Type"), + markdown ? "text/markdown; charset=utf-8" : "text/html", + accept, + ); + assert.equal( + await response.text(), + markdown ? "# Guide" : "

Guide

", + accept, + ); + assert.match(response.headers.get("Vary"), /Accept/); + assert.match(response.headers.get("Link"), /rel="ard"/); + assert.match( + response.headers.get("Link"), + /<\/guide\/index.md>; rel="alternate"/, + ); + } +}); + +test("HEAD and validators use the selected representation", async () => { + for (const method of ["GET", "HEAD"]) { + const response = await worker.fetch( + new Request("https://rea.tools/guide/", { + method, + headers: { Accept: "text/markdown", "If-None-Match": '"html"' }, + }), + assets(), + ); + assert.equal(response.status, 200); + assert.equal(response.headers.get("ETag"), '"markdown"'); + assert.equal(await response.text(), method === "HEAD" ? "" : "# Guide"); + } + for (const [accept, tag] of [ + ["text/html", '"html"'], + ["text/markdown", '"markdown"'], + ]) { + const response = await worker.fetch( + new Request("https://rea.tools/guide/", { + headers: { Accept: accept, "If-None-Match": tag }, + }), + assets(), + ); + assert.equal(response.status, 304); + assert.equal(await response.text(), ""); + assert.match(response.headers.get("Vary"), /Accept/); + } +}); + +test("missing Markdown keeps HTML, and asset redirects and missing routes survive", async () => { + const html = await worker.fetch( + new Request("https://rea.tools/guide/", { + headers: { Accept: "text/markdown" }, + }), + assets({ markdownStatus: 404 }), + ); + assert.equal(await html.text(), "

Guide

"); + for (const [path, status] of [ + ["/guide", 307], + ["/missing/", 404], + ]) { + const response = await worker.fetch( + new Request(`https://rea.tools${path}`, { + headers: { Accept: "text/markdown" }, + }), + assets(), + ); + assert.equal(response.status, status); + } +}); diff --git a/website/wrangler.toml b/website/wrangler.toml index cb3f21b386..7c64886b30 100644 --- a/website/wrangler.toml +++ b/website/wrangler.toml @@ -1,18 +1,21 @@ # Serve only the public website. The default deployment is a workers.dev preview. name = "rea-website-preview" +main = "worker.mjs" compatibility_date = "2026-10-08" workers_dev = true preview_urls = false [assets] directory = "./_site" +binding = "ASSETS" +run_worker_first = ["/", "/*/", "/*.html"] html_handling = "auto-trailing-slash" not_found_handling = "none" [build] cwd = ".." command = "python3 scripts/website.py check" -watch_dir = ["./public", "./locales", "./templates"] +watch_dir = ["./public", "./locales", "./templates", "../.agents/skills/reverse-engineer-anything"] # Public HTTPS depends on zone activation and certificate readiness. [env.production] From 6442ea02cbdabd8cbaa4106bdbe934ea07d0dcef Mon Sep 17 00:00:00 2001 From: JANG BYUNGKUN Date: Tue, 3 Nov 2026 10:37:41 +0900 Subject: [PATCH 06/14] feat(binary-layout): admit ELF64 AArch64 and name the refused host (#8711) * feat(binary-layout): admit ELF64 AArch64 and name the refused host inspect_binary_layout refused every AArch64 ELF, and on a non-x64 host the refusal read "supports Linux x64 only", which looked like a target refusal (#8709 ran on arm64 WSL and suspected an e_machine check). The pwntools adapter now admits little-endian ELF64 AArch64 EXEC/DYN/REL objects alongside the existing profiles, pwntools must report aarch64/64/ little, and the Evidence subject names arm64. The host refusal now names the detected platform/arch and says the target was not read; the host check itself is unchanged. verify:binary:layout cross-compiles an AArch64 executable and relocatable object (never executed) and checks sections, symbols and relocations against GNU readelf, decoding ELF64 r_info as a 32-bit type under a 32-bit symbol index. The real-binary-layout workflow installs the toolchain. Refs #8709 * test(binary-layout): refuse RISC-V as the unsupported machine and assert the host refusal path verify:binary:layout used e_machine 183 (AArch64) as its unsupported target, which this branch now admits; use EM_RISCV (243) instead. The unsupported-host provider test now asserts the refusal names this host's platform/arch and says the target was not read, so reverting the decoder to the old message fails it. --- .github/workflows/real-binary-layout.yml | 6 +-- bridge/pwntools/layout_impl.py | 8 ++-- docs/binary-diagnostics.md | 8 ++-- docs/testing-lanes.md | 6 +-- scripts/verify-binary-layout.mjs | 37 +++++++++++-------- .../BinaryLayoutService.test.ts | 1 + .../binaryDiagnostics/BinaryLayoutService.ts | 1 + .../native/binaryDiagnosticsToolContracts.ts | 2 +- src/domain/native/binaryLayout.test.ts | 5 ++- src/domain/native/binaryLayout.ts | 2 +- src/native/pwntools/PwntoolsDecoder.ts | 19 +++++++--- src/native/pwntools/PwntoolsFailures.test.ts | 12 ++++++ .../pwntoolsLayoutProvider.test.ts | 5 +++ 13 files changed, 75 insertions(+), 37 deletions(-) diff --git a/.github/workflows/real-binary-layout.yml b/.github/workflows/real-binary-layout.yml index a8d84a2234..e312baa5af 100644 --- a/.github/workflows/real-binary-layout.yml +++ b/.github/workflows/real-binary-layout.yml @@ -55,12 +55,12 @@ jobs: - uses: ./.github/actions/portable-build-cache with: build_cache_artifact: ${{ inputs.build_cache_artifact }} - - name: Install ELF32 cross toolchains for source-owned fixtures + - name: Install AArch64 and ELF32 cross toolchains for source-owned fixtures run: | sudo apt-get update sudo apt-get install --yes --no-install-recommends \ - gcc-i686-linux-gnu gcc-arm-linux-gnueabi gcc-mips-linux-gnu gcc-mipsel-linux-gnu \ - libc6-dev-i386-cross libc6-dev-armel-cross libc6-dev-mips-cross libc6-dev-mipsel-cross + gcc-aarch64-linux-gnu gcc-i686-linux-gnu gcc-arm-linux-gnueabi gcc-mips-linux-gnu gcc-mipsel-linux-gnu \ + libc6-dev-arm64-cross libc6-dev-i386-cross libc6-dev-armel-cross libc6-dev-mips-cross libc6-dev-mipsel-cross - name: Acquire exact upstream libraries in an isolated runner environment shell: bash run: | diff --git a/bridge/pwntools/layout_impl.py b/bridge/pwntools/layout_impl.py index 86ec3f623b..a9c112fb70 100644 --- a/bridge/pwntools/layout_impl.py +++ b/bridge/pwntools/layout_impl.py @@ -16,10 +16,10 @@ PROFILE = "pwntools@4.15.0;pyelftools@0.33;unicorn@2.1.2" PACKAGES = {"pwntools": "4.15.0", "pyelftools": "0.33", "unicorn": "2.1.2"} OUTPUT_BYTES = 64 * 1024 * 1024 # (e_machine, ELF class bits, byte order) -> architecture pwntools must also report. -PROFILES = {(62, 64, "little"): "amd64", (3, 32, "little"): "i386", (40, 32, "little"): "arm", - (8, 32, "little"): "mips", (8, 32, "big"): "mips"} -PROFILE_SCOPE = ("Layout profile requires ELF64 x86-64, ELF32 i386, ELF32 ARM little-endian " - "or ELF32 MIPS little- or big-endian objects.") +PROFILES = {(62, 64, "little"): "amd64", (183, 64, "little"): "aarch64", (3, 32, "little"): "i386", + (40, 32, "little"): "arm", (8, 32, "little"): "mips", (8, 32, "big"): "mips"} +PROFILE_SCOPE = ("Layout profile requires ELF64 x86-64, ELF64 AArch64 little-endian, ELF32 i386, " + "ELF32 ARM little-endian or ELF32 MIPS little- or big-endian objects.") class LayoutFailure(Exception): diff --git a/docs/binary-diagnostics.md b/docs/binary-diagnostics.md index 56427673af..cf074b10c3 100644 --- a/docs/binary-diagnostics.md +++ b/docs/binary-diagnostics.md @@ -9,9 +9,11 @@ ordinary binary inspection use the same modular capability. ## Bring your own engine Real verification covers **Linux x64** hosts and **EXEC/DYN/REL** objects in -these profiles: ELF64 x86-64, ELF32 i386, ELF32 little-endian ARM and ELF32 -little- or big-endian MIPS. Other machines, classes and byte orders, including -AArch64 and big-endian ARM, return `unsupported_target`. Provide an absolute +these profiles: ELF64 x86-64, ELF64 little-endian AArch64, ELF32 i386, ELF32 +little-endian ARM and ELF32 little- or big-endian MIPS. Other machines, classes +and byte orders, including big-endian ARM, return `unsupported_target`. Other +hosts, including Linux arm64, return `capability_unavailable` naming the +detected host before any target is read. Provide an absolute Python executable whose environment already contains unchanged pwntools 4.15.0, pyelftools 0.33 and Unicorn 2.1.2: diff --git a/docs/testing-lanes.md b/docs/testing-lanes.md index 5e54caf8a2..4aea0bebc0 100644 --- a/docs/testing-lanes.md +++ b/docs/testing-lanes.md @@ -1108,13 +1108,13 @@ coverage limits. ### Offline binary layout `npm run verify:binary:layout` requires Linux x64, GCC/binutils, the -`i686-linux-gnu`, `arm-linux-gnueabi`, `mips-linux-gnu` and `mipsel-linux-gnu` -cross `gcc`/`readelf` toolchains, absolute +`aarch64-linux-gnu`, `i686-linux-gnu`, `arm-linux-gnueabi`, `mips-linux-gnu` and +`mipsel-linux-gnu` cross `gcc`/`readelf` toolchains, absolute `REA_PWNTOOLS_PYTHON` with pwntools 4.15.0/pyelftools 0.33/Unicorn 2.1.2 and absolute `REA_VERIFY_STRACE_COMMAND`. It compiles ephemeral source-owned ELF fixtures and checks public CLI/MCP, lossless addresses/names, file ranges, mitigation inferences, malformed/unsupported input, original file hashes and -released process ownership. Cross-compiled ELF32 executables and relocatable +released process ownership. Cross-compiled ELF64 AArch64 and ELF32 executables and relocatable objects check the reported class, machine and byte order, and every section name, address, offset and size against GNU `readelf`. Exec syscall tracing must identify only the declared Node/Python launchers; no target binary is executed. Core/debugger claims need diff --git a/scripts/verify-binary-layout.mjs b/scripts/verify-binary-layout.mjs index 64a1b430f4..428daf53f5 100644 --- a/scripts/verify-binary-layout.mjs +++ b/scripts/verify-binary-layout.mjs @@ -38,15 +38,16 @@ if ( !isAbsolute(strace ?? "") ) throw new Error( - "verify:binary:layout requires Linux x64, absolute REA_PWNTOOLS_PYTHON (pwntools 4.15.0/pyelftools 0.33/Unicorn 2.1.2) and REA_VERIFY_STRACE_COMMAND. gcc, ld, strip and the i686/ARM/MIPS/MIPSel linux-gnu cross gcc and readelf must be available. No target is executed.", + "verify:binary:layout requires Linux x64, absolute REA_PWNTOOLS_PYTHON (pwntools 4.15.0/pyelftools 0.33/Unicorn 2.1.2) and REA_VERIFY_STRACE_COMMAND. gcc, ld, strip and the AArch64/i686/ARM/MIPS/MIPSel linux-gnu cross gcc and readelf must be available. No target is executed.", ); const run = createVerifierRun(); const execute = promisify(execFile); -const ELF32_TOOLCHAINS = [ - ["i686-linux-gnu", "EM_386", "little"], - ["arm-linux-gnueabi", "EM_ARM", "little"], - ["mips-linux-gnu", "EM_MIPS", "big"], - ["mipsel-linux-gnu", "EM_MIPS", "little"], +const CROSS_TOOLCHAINS = [ + ["aarch64-linux-gnu", "EM_AARCH64", 64, "little"], + ["i686-linux-gnu", "EM_386", 32, "little"], + ["arm-linux-gnueabi", "EM_ARM", 32, "little"], + ["mips-linux-gnu", "EM_MIPS", 32, "big"], + ["mipsel-linux-gnu", "EM_MIPS", 32, "little"], ]; for (const command of [ "gcc", @@ -54,7 +55,7 @@ for (const command of [ "strip", "/usr/bin/prlimit", strace, - ...ELF32_TOOLCHAINS.flatMap(([prefix]) => [ + ...CROSS_TOOLCHAINS.flatMap(([prefix]) => [ `${prefix}-gcc`, `${prefix}-readelf`, ]), @@ -347,14 +348,15 @@ try { cases++; } } - // ELF32 profiles: cross-compiled, never executed, checked against GNU readelf. + // Cross profiles: cross-compiled, never executed, checked against GNU readelf. for (const [ prefix, machine, + bits, byteOrder, kind, flags, - ] of ELF32_TOOLCHAINS.flatMap((toolchain) => [ + ] of CROSS_TOOLCHAINS.flatMap((toolchain) => [ [...toolchain, "executable", ["-fPIE", "-pie"]], [...toolchain, "relocatable", ["-c", "-fPIC"]], ])) { @@ -423,16 +425,18 @@ try { `readelf relocation count: ${name}`, ); for (const [entryIndex, [, offset, info]] of rows.entries()) { - // ELF32 r_info uses the low byte for type and the upper 24 bits for - // the symbol index. readelf has already decoded the file's byte order. + // ELF32 r_info packs an 8-bit type under a 24-bit symbol index; + // ELF64 packs a 32-bit type under a 32-bit index. readelf has already + // decoded the file's byte order. const decodedInfo = BigInt(`0x${info}`); + const typeBits = bits === 32 ? 8n : 32n; expectedRelocations.push({ section_index: section.index, entry_index: entryIndex, reported_offset: BigInt(`0x${offset}`), - type: Number(decodedInfo & 0xffn), + type: Number(decodedInfo & ((1n << typeBits) - 1n)), symbol_table_index: section.link, - symbol_index: Number(decodedInfo >> 8n), + symbol_index: Number(decodedInfo >> typeBits), }); } } @@ -445,7 +449,7 @@ try { const value = await inspect(mode, path); assert.deepEqual(value.architecture, { machine, - bits: 32, + bits, byte_order: byteOrder, }); assert.equal( @@ -1312,7 +1316,8 @@ try { } } } - unsupported.writeUInt16LE(183, 18); + // EM_RISCV: AArch64 (183) is now an admitted profile. + unsupported.writeUInt16LE(243, 18); const core = Buffer.from(object); core.writeUInt16LE(4, 16); for (const [name, bytes, category] of [ @@ -1337,7 +1342,7 @@ try { "invalid_input", ], ["undersized-program-header", undersizedProgramHeader, "invalid_input"], - ["arm64", unsupported, "unsupported_target"], + ["riscv64", unsupported, "unsupported_target"], ["core", core, "unsupported_target"], ]) { const path = join(root.path, name); diff --git a/src/application/binaryDiagnostics/BinaryLayoutService.test.ts b/src/application/binaryDiagnostics/BinaryLayoutService.test.ts index f8a4c6b878..c087a83a64 100644 --- a/src/application/binaryDiagnostics/BinaryLayoutService.test.ts +++ b/src/application/binaryDiagnostics/BinaryLayoutService.test.ts @@ -58,6 +58,7 @@ it("records the decoded layout once in its Evidence", async () => { it.each([ [{ machine: "EM_X86_64", bits: 64, byte_order: "little" }, "x86_64"], + [{ machine: "EM_AARCH64", bits: 64, byte_order: "little" }, "arm64"], [{ machine: "EM_386", bits: 32, byte_order: "little" }, "x86"], [{ machine: "EM_ARM", bits: 32, byte_order: "little" }, "arm"], [{ machine: "EM_MIPS", bits: 32, byte_order: "big" }, "mips"], diff --git a/src/application/binaryDiagnostics/BinaryLayoutService.ts b/src/application/binaryDiagnostics/BinaryLayoutService.ts index 87dd9723b6..757ab7eb6b 100644 --- a/src/application/binaryDiagnostics/BinaryLayoutService.ts +++ b/src/application/binaryDiagnostics/BinaryLayoutService.ts @@ -14,6 +14,7 @@ import { parseAnalysisInput } from "../AnalysisInput.js"; const OPERATION = "inspect_binary_layout"; const EVIDENCE_ARCHITECTURES = { EM_X86_64: "x86_64", + EM_AARCH64: "arm64", EM_386: "x86", EM_ARM: "arm", EM_MIPS: "mips", diff --git a/src/contracts/native/binaryDiagnosticsToolContracts.ts b/src/contracts/native/binaryDiagnosticsToolContracts.ts index eba10d04fa..7bc97ecbc4 100644 --- a/src/contracts/native/binaryDiagnosticsToolContracts.ts +++ b/src/contracts/native/binaryDiagnosticsToolContracts.ts @@ -245,7 +245,7 @@ export const BINARY_DIAGNOSTICS_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_binary_layout"), kind: "native-provider", description: - "Inspect an explicit local binary without launching it as a host process. Returns file-backed section/segment ranges, linked addresses, original symbol/REL/RELA table identities, complete encoded RELR tables and upstream-derived offsets with unknown per-word locations/addends, raw name bytes, dependency/interpreter names and static mitigation inferences inline with artifact SHA-256 Evidence. Profile: EXEC/DYN/REL ELF64 x86-64, ELF32 i386, ELF32 little-endian ARM and ELF32 little- or big-endian MIPS on Linux x64 via caller-supplied Python with unchanged pwntools 4.15.0, pyelftools 0.33 and Unicorn 2.1.2. Runtime addresses/library paths, overall relocation inventory completeness and complete derived GOT/PLT coverage remain unknown. Zero executable entry values mean absence; relocatable entries are not applicable. Upstream can emulate PLT instructions with Unicorn for derived static maps. Uses bounded complete output and owned temporary files/processes; malformed, unsupported or oversized objects return no partial success. Select detail summary to retain the complete layout Evidence in this session and receive only its summary view, then inspect sections, symbols, mitigations or linkage with inspect_analysis_view. pwntools_timeout_ms is each owned pwntools command's deadline (omitted means 30000 ms, range 1..2147483647, next call). cpuSeconds 30 and the output, diagnostic, and address-space byte allowances have no runtime override.", + "Inspect an explicit local binary without launching it as a host process. Returns file-backed section/segment ranges, linked addresses, original symbol/REL/RELA table identities, complete encoded RELR tables and upstream-derived offsets with unknown per-word locations/addends, raw name bytes, dependency/interpreter names and static mitigation inferences inline with artifact SHA-256 Evidence. Profile: EXEC/DYN/REL ELF64 x86-64, ELF64 little-endian AArch64, ELF32 i386, ELF32 little-endian ARM and ELF32 little- or big-endian MIPS on Linux x64 via caller-supplied Python with unchanged pwntools 4.15.0, pyelftools 0.33 and Unicorn 2.1.2. Runtime addresses/library paths, overall relocation inventory completeness and complete derived GOT/PLT coverage remain unknown. Zero executable entry values mean absence; relocatable entries are not applicable. Upstream can emulate PLT instructions with Unicorn for derived static maps. Uses bounded complete output and owned temporary files/processes; malformed, unsupported or oversized objects return no partial success. Select detail summary to retain the complete layout Evidence in this session and receive only its summary view, then inspect sections, symbols, mitigations or linkage with inspect_analysis_view. pwntools_timeout_ms is each owned pwntools command's deadline (omitted means 30000 ms, range 1..2147483647, next call). cpuSeconds 30 and the output, diagnostic, and address-space byte allowances have no runtime override.", inputSchema: inspectBinaryLayoutRequestSchema, outputSchema: analysisDetailOutputSchemaOf( binaryLayoutSchema, diff --git a/src/domain/native/binaryLayout.test.ts b/src/domain/native/binaryLayout.test.ts index 172c0c0e99..1d4e8a9410 100644 --- a/src/domain/native/binaryLayout.test.ts +++ b/src/domain/native/binaryLayout.test.ts @@ -122,6 +122,9 @@ it("retains high linked values and NOBITS without inventing file backing", () => }); it.each([ + [{ machine: "EM_AARCH64", bits: 64, byte_order: "little" }, true], + [{ machine: "EM_AARCH64", bits: 64, byte_order: "big" }, false], + [{ machine: "EM_AARCH64", bits: 32, byte_order: "little" }, false], [{ machine: "EM_386", bits: 32, byte_order: "little" }, true], [{ machine: "EM_ARM", bits: 32, byte_order: "little" }, true], [{ machine: "EM_MIPS", bits: 32, byte_order: "little" }, true], @@ -150,7 +153,7 @@ it.each([ if (problem === "file-backed-outside") section.file_backing = "file"; if (problem === "header-outside") section.header_location.offset = "0x80"; if (problem === "wrong-index") section.index = 1; - if (problem === "wrong-machine") value.architecture.machine = "EM_AARCH64"; + if (problem === "wrong-machine") value.architecture.machine = "EM_RISCV"; if (problem === "outside-elf64") value.entry_point.reported_value = "0x10000000000000000"; if (problem === "noncanonical-base64") section.name.bytes_base64 = "AR=="; diff --git a/src/domain/native/binaryLayout.ts b/src/domain/native/binaryLayout.ts index c60a728d51..d80c9ca4ca 100644 --- a/src/domain/native/binaryLayout.ts +++ b/src/domain/native/binaryLayout.ts @@ -35,7 +35,7 @@ const binaryLayoutObjectSchema = z.strictObject({ format: z.literal("elf"), architecture: z.union([ z.strictObject({ - machine: z.literal("EM_X86_64"), + machine: z.enum(["EM_X86_64", "EM_AARCH64"]), bits: z.literal(64), byte_order: z.literal("little"), }), diff --git a/src/native/pwntools/PwntoolsDecoder.ts b/src/native/pwntools/PwntoolsDecoder.ts index 60b8bc967a..5bfbc1ea1f 100644 --- a/src/native/pwntools/PwntoolsDecoder.ts +++ b/src/native/pwntools/PwntoolsDecoder.ts @@ -69,8 +69,12 @@ import { PWNTOOLS_LIMITS } from "./PwntoolsRelease.js"; import { PWNTOOLS_PROVIDER_IDENTITY } from "./PwntoolsProviderIdentity.js"; import { pwntoolsInputBytes } from "./PwntoolsInputLimit.js"; -const HOST_REQUIREMENT = - "The initial real-verified pwntools artifact profile supports Linux x64 only."; +/** Names the detected host so a host refusal is not read as a target refusal. */ +export const pwntoolsHostRequirement = ( + platform: NodeJS.Platform, + arch: string, +): string => + `The real-verified pwntools artifact profile runs only on a Linux x64 host; this host is ${platform}/${arch}. The selected target was not read.`; /** The pwntools profile runs only on Linux x64; discovery and execution share this check. */ export const pwntoolsHostSupported = ( @@ -290,13 +294,18 @@ export class PwntoolsDecoder< throw new AnalysisCancelledError(OPERATION, { cause: options.signal.reason, }); - if (!pwntoolsHostSupported(process.platform, process.arch)) + if (!pwntoolsHostSupported(process.platform, process.arch)) { + const requirement = pwntoolsHostRequirement( + process.platform, + process.arch, + ); throw new AnalysisCapabilityUnavailableError( this.identity.id, OPERATION, - HOST_REQUIREMENT, - { userMessage: HOST_REQUIREMENT }, + requirement, + { userMessage: requirement }, ); + } if (!isAbsolute(selectedPath)) throw pwntoolsUnavailable( "Set REA_PWNTOOLS_PYTHON to an absolute caller-supplied Python executable with pwntools 4.15.0, pyelftools 0.33 and Unicorn 2.1.2; REA never installs Python or packages.", diff --git a/src/native/pwntools/PwntoolsFailures.test.ts b/src/native/pwntools/PwntoolsFailures.test.ts index bec9ac4be3..fb3840b066 100644 --- a/src/native/pwntools/PwntoolsFailures.test.ts +++ b/src/native/pwntools/PwntoolsFailures.test.ts @@ -2,6 +2,10 @@ import { expect, it } from "vitest"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; import { OwnedCommandFailure } from "../../process/OwnedCommand.js"; +import { + pwntoolsHostRequirement, + pwntoolsHostSupported, +} from "./PwntoolsDecoder.js"; import { pwntoolsDecoderFailure } from "./PwntoolsFailures.js"; it.each([undefined, null, false, 0, "stop", { stop: true }, new Error("stop")])( @@ -149,3 +153,11 @@ it("retains legacy owner causes without guessing cancellation from error text", "ProviderAdapterError", ); }); + +it("names the refused host instead of implying the target was refused", () => { + expect(pwntoolsHostSupported("linux", "arm64")).toBe(false); + const message = pwntoolsHostRequirement("linux", "arm64"); + expect(message).toContain("Linux x64 host"); + expect(message).toContain("this host is linux/arm64"); + expect(message).toContain("The selected target was not read."); +}); diff --git a/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts b/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts index 1f2156ce19..b1e816758b 100644 --- a/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts +++ b/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts @@ -677,6 +677,11 @@ it.runIf(unsupportedHost)( const projected = projectAnalysisError(result.error); expect(projected.message).toContain("Linux x64"); expect(projected.remediation.action).toContain("Linux x64"); + // The refusal names this host, not the target, which was never read. + expect(projected.message).toContain( + `this host is ${process.platform}/${process.arch}`, + ); + expect(projected.message).toContain("The selected target was not read."); }, ); From b7911b2d30b9612969390e2d6a7bcbd7a24e67bf Mon Sep 17 00:00:00 2001 From: ShuaixinHuang <1836656514@qq.com> Date: Tue, 3 Nov 2026 09:38:01 +0800 Subject: [PATCH 07/14] fix(evm): explain limiter configuration and access failures (#8669) --- docs/evm-bytecode.md | 9 ++ src/evm/EvmoleInterfaceProvider.ts | 33 +++++- .../evm/evmLimiterAvailability.test.ts | 108 +++++++++++++----- .../evm/evmoleInterfaceProvider.test.ts | 2 +- 4 files changed, 121 insertions(+), 31 deletions(-) diff --git a/docs/evm-bytecode.md b/docs/evm-bytecode.md index 0b4d97bd65..e215f0028e 100644 --- a/docs/evm-bytecode.md +++ b/docs/evm-bytecode.md @@ -28,6 +28,15 @@ The host must already provide util-linux `prlimit`, normally `/usr/bin/prlimit`; no system tools and changes no user configuration. [Upstream provenance and license](https://github.com/morluto/rea/blob/main/third_party/evmole/README.md) are packaged. +Limiter discovery validates the selected path without launching it. A relative +path or directory is reported as `not_configured`, a missing file as +`executable_missing`, and denied access or another filesystem-probe failure as +`runtime_unavailable`. Recovery names the selected path and +`REA_EVM_PRLIMIT_COMMAND`; denied access asks for execute permission and +filesystem access. The original admission or system code stays in diagnostics. +An available filesystem probe does not verify the selected tool's version or +util-linux compatibility, and REA does not substitute another command. + ## Evidence semantics - `encoding` is required: raw bytes or UTF-8 hex, with optional `0x` prefix and diff --git a/src/evm/EvmoleInterfaceProvider.ts b/src/evm/EvmoleInterfaceProvider.ts index 2a72fef70d..4dfa48ba8e 100644 --- a/src/evm/EvmoleInterfaceProvider.ts +++ b/src/evm/EvmoleInterfaceProvider.ts @@ -1,4 +1,5 @@ import { EvmAdmissionError } from "./EvmAdmissionError.js"; +import type { ProviderRejectionCode } from "../domain/providerSelection.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { safeParseUtf8Json } from "../domain/safeJson.js"; @@ -115,6 +116,7 @@ type EvmLimiterSelection = { readonly diagnostics: Readonly>; }; type EvmLimiterUnavailable = EvmLimiterSelection & { + readonly code: ProviderRejectionCode; readonly reason: string; readonly cause: unknown; }; @@ -159,16 +161,39 @@ const selectEvmLimiter = async ( if (cause instanceof EvmAdmissionError) return err({ path, + code: "not_configured", diagnostics: { ...diagnostics, code: cause.code, ...cause.detail, }, - reason: cause.code, + reason: `Set REA_EVM_PRLIMIT_COMMAND to an absolute executable regular file for caller-supplied util-linux prlimit. The selected path ${path} ${cause.code === "prlimit_not_absolute" ? "is not absolute" : "is not a regular file"}.`, cause, }); - const reason = `Offline EVM interface inspection requires caller-supplied util-linux prlimit: ${path}; ${cause instanceof Error ? cause.message : String(cause)}`; - return err({ path, diagnostics, reason, cause }); + const systemCode = + cause instanceof Error && + "code" in cause && + typeof cause.code === "string" + ? cause.code + : undefined; + const permissionDenied = systemCode === "EACCES" || systemCode === "EPERM"; + const missing = systemCode === "ENOENT" || systemCode === "ENOTDIR"; + const recovery = permissionDenied + ? "Grant execute permission and filesystem access to the selected file, or set REA_EVM_PRLIMIT_COMMAND to an accessible absolute executable regular file for util-linux prlimit." + : missing + ? "Set REA_EVM_PRLIMIT_COMMAND to an existing absolute executable regular file for util-linux prlimit." + : "Check the selected file and filesystem access, or set REA_EVM_PRLIMIT_COMMAND to an accessible absolute executable regular file for util-linux prlimit."; + const reason = `Offline EVM interface inspection requires caller-supplied util-linux prlimit: ${path}; ${cause instanceof Error ? cause.message : String(cause)}. ${recovery}`; + return err({ + path, + code: missing ? "executable_missing" : "runtime_unavailable", + diagnostics: { + ...diagnostics, + ...(systemCode === undefined ? {} : { system_code: systemCode }), + }, + reason, + cause, + }); } }; @@ -193,7 +218,7 @@ export const evmLimiterAvailability = async ( } : { status: "unavailable", - code: "executable_missing", + code: selected.error.code, reason: selected.error.reason, // The limiter owner does not yet author a separate repair (#8508). remediation: selected.error.reason, diff --git a/tests/boundary/evm/evmLimiterAvailability.test.ts b/tests/boundary/evm/evmLimiterAvailability.test.ts index d8ad789adf..7684146571 100644 --- a/tests/boundary/evm/evmLimiterAvailability.test.ts +++ b/tests/boundary/evm/evmLimiterAvailability.test.ts @@ -1,4 +1,7 @@ import { expect, it } from "vitest"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { withVerifierCleanup } from "../../../scripts/lib/verifier-cleanup.mjs"; import { buildCapabilityInventory } from "../../../src/application/CapabilityInventory.js"; @@ -41,33 +44,43 @@ it("probes the default limiter with executable and regular-file checks", async ( ]); }); -it("uses the exact absolute override and reports a failed executable check", async () => { - const limiter = "/fixture/util-linux/prlimit"; - const availability = await evmLimiterAvailability( - { REA_EVM_PRLIMIT_COMMAND: limiter }, - { - ...linux, - fs: { - access: async (path) => { - expect(path).toBe(limiter); - throw Object.assign(new Error("permission denied"), { - code: "EACCES", - }); - }, - stat: async () => { - throw new Error("stat must not run after access failure"); +it.each(["EACCES", "EPERM"])( + "uses the exact absolute override and reports denied access: %s", + async (code) => { + const limiter = "/fixture/util-linux/prlimit"; + const availability = await evmLimiterAvailability( + { REA_EVM_PRLIMIT_COMMAND: limiter }, + { + ...linux, + fs: { + access: async (path) => { + expect(path).toBe(limiter); + throw Object.assign(new Error("permission denied"), { + code, + }); + }, + stat: async () => { + throw new Error("stat must not run after access failure"); + }, }, }, - }, - ); + ); - expect(availability).toMatchObject({ - status: "unavailable", - code: "executable_missing", - reason: expect.stringContaining(limiter), - diagnostics: { limiter_path: limiter }, - }); -}); + expect(availability).toMatchObject({ + status: "unavailable", + code: "runtime_unavailable", + reason: expect.stringContaining(limiter), + diagnostics: { limiter_path: limiter, system_code: code }, + }); + if (availability === undefined) throw new Error("Expected limiter probe"); + const entry = buildCapabilityInventory(status, { + ...basePolicy, + evmLimiterAvailability: availability, + }).find(({ name }) => name === "inspect_evm_interface"); + expect(entry?.remediation).toContain("REA_EVM_PRLIMIT_COMMAND"); + expect(entry?.remediation).toContain("execute permission"); + }, +); it("rejects a relative override and non-file without probing an alternate command", async () => { const relative = await evmLimiterAvailability( @@ -84,7 +97,8 @@ it("rejects a relative override and non-file without probing an alternate comman ); expect(relative).toMatchObject({ status: "unavailable", - reason: "prlimit_not_absolute", + code: "not_configured", + reason: expect.stringContaining("absolute executable regular file"), diagnostics: { limiter_path: "prlimit", code: "prlimit_not_absolute", @@ -104,11 +118,53 @@ it("rejects a relative override and non-file without probing an alternate comman ); expect(directory).toMatchObject({ status: "unavailable", - reason: "prlimit_not_file", + code: "not_configured", + reason: expect.stringContaining("absolute executable regular file"), diagnostics: expect.objectContaining({ code: "prlimit_not_file" }), }); }); +it("delivers selected-path recovery from real filesystem probes to capability output", async () => { + const root = await createTestTempDirectory("rea-evm-limiter-recovery-"); + const executable = join(root, "caller-selected-limiter"); + await writeFile(executable, "not executed by the readiness probe", { + mode: 0o700, + }); + for (const [path, expectedCode, expectedReason] of [ + ["prlimit", "not_configured", "absolute executable regular file"], + [root, "not_configured", "absolute executable regular file"], + [ + join(root, "missing-limiter"), + "executable_missing", + "REA_EVM_PRLIMIT_COMMAND", + ], + [executable, null, null], + ] as const) { + const availability = await evmLimiterAvailability( + { REA_EVM_PRLIMIT_COMMAND: path }, + linux, + ); + expect(availability?.code).toBe(expectedCode); + expect(availability?.diagnostics.limiter_path).toBe(path); + if (availability === undefined) throw new Error("Expected limiter probe"); + const entry = buildCapabilityInventory(status, { + ...basePolicy, + evmLimiterAvailability: availability, + }).find(({ name }) => name === "inspect_evm_interface"); + if (expectedReason === null) { + expect(entry).toMatchObject({ available: true, remediation: null }); + } else { + expect(entry).toMatchObject({ + available: false, + reason: "provider_missing", + }); + expect(entry?.remediation).toContain(expectedReason); + expect(entry?.remediation).toContain(path); + expect(entry?.remediation).toContain("util-linux prlimit"); + } + } +}); + it("rechecks on each observation so inventory follows the selected executable's current state", async () => { let accessible = true; const fs = { diff --git a/tests/boundary/evm/evmoleInterfaceProvider.test.ts b/tests/boundary/evm/evmoleInterfaceProvider.test.ts index 9edd10309e..1e98c38490 100644 --- a/tests/boundary/evm/evmoleInterfaceProvider.test.ts +++ b/tests/boundary/evm/evmoleInterfaceProvider.test.ts @@ -970,7 +970,7 @@ it.runIf(!unsupportedHost).each(["directory", "missing-interpreter"])( }); if (scenario === "directory") expect(result.error).toMatchObject({ - reason: "prlimit_not_file", + reason: expect.stringContaining("absolute executable regular file"), diagnostics: { limiter_path: limiter, code: "prlimit_not_file", From 22c493ad5c9d2fc9825e28ab0f6cf9fb1fdf0871 Mon Sep 17 00:00:00 2001 From: ShuaixinHuang <1836656514@qq.com> Date: Tue, 3 Nov 2026 09:38:12 +0800 Subject: [PATCH 08/14] fix(native): preserve implementation file locations (#8667) fix(native): keep UI handler offsets distinct from method records --- docs/native-investigation.md | 5 + .../native/nativeInvestigationGraph.test.ts | 92 +++++++++++--- src/domain/native/nativeInvestigationGraph.ts | 3 +- .../mcp/nativeUiPartialCallRoutesMcp.test.ts | 115 +++++++++++++++++- 4 files changed, 192 insertions(+), 23 deletions(-) diff --git a/docs/native-investigation.md b/docs/native-investigation.md index abd1424ea5..83b414f5bd 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -388,6 +388,11 @@ sites remain distinct. A static route does not establish runtime reachability. Swift/closure dispatch requires resolved static references; unavailable ABI forms remain unknown. +Encoded Objective-C method records retain their own locations in dispatch +Evidence. A handler node uses the implementation address with an unknown file +offset unless that function already has an independently located graph node; +the method-record offset does not locate the handler's code. + Incomplete reference kinds do not discard known typed calls or partial provider call references. A `provider_call` with `observed` resolution retains the provider-reported call site and endpoint without claiming generic direct, diff --git a/src/domain/native/nativeInvestigationGraph.test.ts b/src/domain/native/nativeInvestigationGraph.test.ts index 13cb25c001..4975466635 100644 --- a/src/domain/native/nativeInvestigationGraph.test.ts +++ b/src/domain/native/nativeInvestigationGraph.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { objcSwiftMetadataSchema } from "./objcSwiftMetadata.js"; +import { + inspectNativeDispatchMetadata, + objcSwiftMetadataSchema, +} from "./objcSwiftMetadata.js"; import { nativeInvestigationGraphSchema, @@ -191,9 +194,18 @@ describe("native investigation graph traces", () => { }); describe("Interface Builder dispatch joins", () => { - it.each(["BuildViewController", "BuildView", "BuildButton", "BuildHandler"])( - "joins an action to its declared %s receiver class", - (className) => { + it.each([ + { + className: "BuildViewController", + recordOffset: 1944, + existingOffset: null, + }, + { className: "BuildView", recordOffset: 6040, existingOffset: null }, + { className: "BuildButton", recordOffset: null, existingOffset: null }, + { className: "BuildHandler", recordOffset: 1944, existingOffset: 1916 }, + ])( + "joins the declared $className without mixing record and implementation offsets", + ({ className, recordOffset, existingOffset }) => { const ui = buildInterfaceBuilderAnalysis({ targetSha256: "b".repeat(64), toolVersion: "test", @@ -221,38 +233,80 @@ describe("Interface Builder dispatch joins", () => { ], limits: interfaceBuilderLimitsSchema.parse({}), }); - const joined = joinInterfaceBuilderDispatch(ui.graph, { - objc_classes: [], - objc_protocols: [], - objc_categories: [], - swift_decls: [], - objc_ivars: [], - objc_protocol_records: [], + const recordLocation = { + address: recordOffset === null ? "0x1000" : "0x2000", + file_offset: recordOffset, + }; + const recordEvidence = [ + { + kind: recordOffset === null ? "symbol" : "binary_metadata", + description: "Explicit synthetic method-record location", + location: recordLocation, + artifact_path: "/synthetic/Fixture", + artifact_sha256: "b".repeat(64), + }, + ]; + const mappedEvidence = [ + { + ...recordEvidence[0], + kind: "reference", + description: "Explicit independently mapped handler", + location: { address: "0x1000", file_offset: existingOffset }, + }, + ]; + const inputGraph = + existingOffset === null + ? ui.graph + : nativeInvestigationGraphSchema.parse({ + ...ui.graph, + nodes: [ + ...ui.graph.nodes, + { + id: "native:function:0x1000", + kind: "function", + name: "Already mapped handler", + location: { address: "0x1000", file_offset: existingOffset }, + attributes: {}, + evidence: mappedEvidence, + }, + ], + }); + const metadata = objcSwiftMetadataSchema.parse({ + ...inspectNativeDispatchMetadata([], 1), objc_dispatch_implementations: [ { class_name: className, selector: "buildTapped:", method_type: "instance", implementation_address: "0x1000", - location: { address: "0x1000", file_offset: 0 }, + location: recordLocation, decode: { status: "decoded", reason: null }, - evidence, + evidence: recordEvidence, }, ], - swift_conformances: [], - swift_dispatch_slots: [], - swift_symbols: [], - relative_pointers: [], - coverage: [], - db_save_result: null, }); + const joined = joinInterfaceBuilderDispatch(inputGraph, metadata); expect(joined.edges).toContainEqual( expect.objectContaining({ relation: "objc_dispatch", resolution: "resolved", to: "native:function:0x1000", + evidence: expect.arrayContaining(recordEvidence), }), ); + const handler = joined.nodes.find( + ({ id }) => id === "native:function:0x1000", + ); + expect(handler?.location).toEqual({ + address: "0x1000", + file_offset: existingOffset, + }); + expect(handler?.evidence).toEqual( + existingOffset === null ? recordEvidence : mappedEvidence, + ); + expect(metadata.objc_dispatch_implementations[0]?.location).toEqual( + recordLocation, + ); }, ); }); diff --git a/src/domain/native/nativeInvestigationGraph.ts b/src/domain/native/nativeInvestigationGraph.ts index b51ad6f63e..03c56bc705 100644 --- a/src/domain/native/nativeInvestigationGraph.ts +++ b/src/domain/native/nativeInvestigationGraph.ts @@ -391,7 +391,8 @@ export const joinInterfaceBuilderDispatch = ( name: `${implementation.class_name} ${selector}`, location: { address: implementation.implementation_address, - file_offset: implementation.location.file_offset, + // The implementation location identifies the method record, not its code. + file_offset: null, }, attributes: { class_name: implementation.class_name, diff --git a/tests/boundary/mcp/nativeUiPartialCallRoutesMcp.test.ts b/tests/boundary/mcp/nativeUiPartialCallRoutesMcp.test.ts index 941253bf05..d0ce556ea1 100644 --- a/tests/boundary/mcp/nativeUiPartialCallRoutesMcp.test.ts +++ b/tests/boundary/mcp/nativeUiPartialCallRoutesMcp.test.ts @@ -14,6 +14,11 @@ import { } from "../../../src/domain/analysisErrorCore.js"; import { inspectNativeDispatchMetadata } from "../../../src/domain/native/objcSwiftMetadata.js"; import { nativeInvestigationTraceSchema } from "../../../src/domain/native/nativeInvestigationGraph.js"; +import { + buildInterfaceBuilderAnalysis, + interfaceBuilderLimitsSchema, +} from "../../../src/domain/apple/interfaceBuilderGraph.js"; +import { jsonValueSchema } from "../../../src/domain/jsonValue.js"; import { err, ok, type Result } from "../../../src/domain/result.js"; import { createServer } from "../../../src/server/createServer.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; @@ -111,6 +116,12 @@ const noClassifiedReferences = { }; type Plan = { + readonly ui?: ReturnType; + readonly implementations?: ReturnType< + typeof inspectNativeDispatchMetadata + >["objc_dispatch_implementations"]; + readonly action?: string; + readonly maxDepth?: number; readonly references?: unknown; readonly referencesFailure?: AnalysisError; readonly uiFailure?: AnalysisError; @@ -142,6 +153,12 @@ function publicTrace(plan: Plan, maxNodes = 12, maxEdges = 500) { plan.referencesFailure !== undefined ) return err(plan.referencesFailure); + if (operation === "decode_interface_builder" && plan.ui !== undefined) + return ok( + createAnalysisExecution(jsonValueSchema.parse(plan.ui), identity, { + subject, + }), + ); if (operation === "decode_interface_builder") return err( new AnalysisCapabilityUnavailableError( @@ -157,7 +174,12 @@ function publicTrace(plan: Plan, maxNodes = 12, maxEdges = 500) { target_sha256: sha256, provider: identity, analysis_profile_digest: null, - result: inspectNativeDispatchMetadata([], 1), + result: { + ...inspectNativeDispatchMetadata([], 1), + ...(plan.implementations === undefined + ? {} + : { objc_dispatch_implementations: plan.implementations }), + }, }, identity, { subject }, @@ -220,10 +242,10 @@ function publicTrace(plan: Plan, maxNodes = 12, maxEdges = 500) { const result = await client.callTool({ name: "trace_native_ui_action", arguments: { - action: "0x1000", + action: plan.action ?? "0x1000", max_nodes: maxNodes, max_edges: maxEdges, - max_depth: 1, + max_depth: plan.maxDepth ?? 1, }, }); return result; @@ -237,6 +259,93 @@ const traceFrom = (result: { structuredContent?: unknown }) => }) .parse(result.structuredContent).normalized_result; +it.each([ + { label: "thin record", recordOffset: 1944 }, + { label: "selected-slice container record", recordOffset: 6040 }, + { label: "symbol-only record", recordOffset: null }, +])( + "keeps handler offsets distinct from $label Evidence through public MCP", + async ({ recordOffset }) => { + // Explicit synthetic decoded facts; no AppKit, Ghidra or Kuna runtime claim. + const ui = buildInterfaceBuilderAnalysis({ + targetSha256: sha256, + toolVersion: "synthetic", + documents: [ + { + relativePath: "Main.storyboardc/scene.nib/objects.nib", + archiveSha256: "b".repeat(64), + documentKind: "storyboard_scene", + raw: { + "com.apple.ibtool.document.objects": { + receiver: { customClass: "AuditController" }, + button: { class: "UIButton" }, + }, + "com.apple.ibtool.document.connections": { + button: [ + { + type: "action", + label: "runAction:", + destinationId: "receiver", + }, + ], + }, + }, + }, + ], + limits: interfaceBuilderLimitsSchema.parse({}), + }); + const recordLocation = { + address: recordOffset === null ? "0x10000077c" : "0x100000798", + file_offset: recordOffset, + }; + const recordEvidence = [ + { + kind: + recordOffset === null + ? ("symbol" as const) + : ("binary_metadata" as const), + description: "Explicit synthetic method entry", + location: recordLocation, + artifact_path: subject.path, + artifact_sha256: sha256, + }, + ]; + const response = await publicTrace({ + ui, + action: "runAction:", + maxDepth: 4, + references: completeReferences([]), + callees: [], + implementations: [ + { + class_name: "AuditController", + selector: "runAction:", + method_type: "instance", + implementation_address: "0x10000077c", + location: recordLocation, + decode: { status: "decoded", reason: null }, + evidence: recordEvidence, + }, + ], + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + const trace = traceFrom(response); + const handler = trace.nodes.find( + ({ id }) => id === "native:function:0x10000077c", + ); + expect(handler?.location).toEqual({ + address: "0x10000077c", + file_offset: null, + }); + expect(handler?.evidence).toEqual(recordEvidence); + expect( + trace.edges.find(({ relation }) => relation === "objc_dispatch") + ?.evidence, + ).toEqual(expect.arrayContaining(recordEvidence)); + expect(recordEvidence[0]?.location).toEqual(recordLocation); + }, +); + it("keeps typed callsite evidence and distinct fallback-provider routes for mixed reference kinds", async () => { const response = await publicTrace({}); expect(response.isError).not.toBe(true); From 79766ca96a22ee0b6e04c3681546d492702b4ae7 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:41:33 +0800 Subject: [PATCH 09/14] fix(website): identify all public deployment verification requests --- .github/workflows/website-pages.yml | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/.github/workflows/website-pages.yml b/.github/workflows/website-pages.yml index 9bfa7b630e..d4184a879b 100644 --- a/.github/workflows/website-pages.yml +++ b/.github/workflows/website-pages.yml @@ -154,6 +154,15 @@ jobs: from urllib.parse import urljoin, urlsplit from urllib.request import Request, urlopen + def open_public(url, accept=None): + headers = { + "Cache-Control": "no-cache", + "User-Agent": "REA-Website-Verifier/1.0 (+https://github.com/morluto/rea)", + } + if accept: + headers["Accept"] = accept + return urlopen(Request(url, headers=headers), timeout=10) + expected = os.environ["GITHUB_SHA"] pages_url = os.environ["PAGES_URL"] if not pages_url: @@ -169,11 +178,7 @@ jobs: for name, base in list(pending.items()): url = urljoin(base, "deployment.json") + "?commit=" + expected try: - request = Request(url, headers={ - "Cache-Control": "no-cache", - "User-Agent": "REA-Website-Verifier/1.0 (+https://github.com/morluto/rea)", - }) - with urlopen(request, timeout=10) as response: + with open_public(url) as response: actual = json.load(response).get("commit") if actual == expected: print(f"{name} serves {expected}: {base}") @@ -189,21 +194,20 @@ jobs: if pending: sys.exit("Could not verify published revision on: " + ", ".join(pending)) for name, base in hosts.items(): - with urlopen(urljoin(base, ".well-known/agent-skills/index.json"), timeout=15) as response: + with open_public(urljoin(base, ".well-known/agent-skills/index.json")) as response: skill, = json.load(response)["skills"] # Fetch from each selected host, including the /rea/ mirror. artifact_url = urljoin(base, urlsplit(skill["url"]).path.lstrip("/")) - with urlopen(artifact_url, timeout=15) as response: + with open_public(artifact_url) as response: digest = "sha256:" + hashlib.sha256(response.read()).hexdigest() if digest != skill["digest"]: sys.exit(name + " serves a skill archive with the wrong digest") for manifest in ("ard.json", "ai-catalog.json"): - with urlopen(urljoin(base, ".well-known/" + manifest), timeout=15) as response: + with open_public(urljoin(base, ".well-known/" + manifest)) as response: if not json.load(response).get("entries"): sys.exit(name + " serves an empty discovery manifest") if name == "Cloudflare": - request = Request(base, headers={"Accept": "text/markdown"}) - with urlopen(request, timeout=15) as response: + with open_public(base, accept="text/markdown") as response: vary = {value.strip().lower() for value in response.headers.get("Vary", "").split(",")} if response.headers.get_content_type() != "text/markdown" or "accept" not in vary: sys.exit("Cloudflare did not negotiate Markdown with Vary: Accept") From 6259ac41a48cd79dc0b7815970ba00283dde5a48 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:49:49 +0800 Subject: [PATCH 10/14] fix(mcp): guide target recovery for scoped tools (#8659) * fix(mcp): guide target recovery for scoped tools * test(mcp): cover unscoped target recovery * fix(mcp): warn before scoped session restart * refactor(mcp): own scoped target recovery guidance * fix(mcp): include unknown history in restart guidance --- src/server/ToolResultDelivery.ts | 29 ++++++- src/server/createServer.ts | 9 +- src/server/recoveryInstructions.ts | 4 + .../mcp/targetContentFramingMcp.test.ts | 86 +++++++++++++++++++ 4 files changed, 123 insertions(+), 5 deletions(-) diff --git a/src/server/ToolResultDelivery.ts b/src/server/ToolResultDelivery.ts index 594ed43bcd..3fce413975 100644 --- a/src/server/ToolResultDelivery.ts +++ b/src/server/ToolResultDelivery.ts @@ -10,6 +10,7 @@ import { type AnalysisErrorProjection, } from "../domain/analysisErrorProjection.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import { NoBinaryOpenError } from "../domain/configurationErrors.js"; import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; import type { Result } from "../domain/result.js"; import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js"; @@ -104,12 +105,22 @@ export class ToolResultDelivery { selected?: JsonValue, viewSource?: JsonValue, ) => Readonly> = () => ({}), + private readonly noTargetRecovery?: string, ) { this.maximumResponseBytes = maximumResponseBytes; this.resultBudgetBytes = maximumResponseBytes - 1024; Object.freeze(this); } + /** Bind scope-specific recovery without mutating a shared delivery policy. */ + withNoTargetRecovery(recovery: string): ToolResultDelivery { + return new ToolResultDelivery( + this.maximumResponseBytes, + this.nativeContinuation, + recovery, + ); + } + /** Serialize a plain application result or its actionable error. */ toCallToolResult( result: Result, @@ -184,10 +195,20 @@ export class ToolResultDelivery { error: AnalysisError, targetContent: boolean, ): CallToolResult { - return this.projectedErrorResult( - projectAnalysisError(error), - targetContent, - ); + const projected = projectAnalysisError(error); + if ( + error instanceof NoBinaryOpenError && + this.noTargetRecovery !== undefined + ) + return this.projectedErrorResult( + { + ...projected, + message: this.noTargetRecovery, + remediation: { action: this.noTargetRecovery }, + }, + targetContent, + ); + return this.projectedErrorResult(projected, targetContent); } /** Retain valid partial Evidence and reference it only after recording succeeds. */ diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 030a94eede..ee9227d082 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -3,6 +3,7 @@ import { type CapabilityServiceOptions, } from "../composition/capabilityServices.js"; import { registerToolFamilies } from "../contracts/toolFamilyIds.js"; +import { SCOPED_NO_TARGET_RECOVERY } from "./recoveryInstructions.js"; import { AnalysisSettings } from "../application/AnalysisSettings.js"; import { parseMcpInputBudget } from "../config/mcpInputBudget.js"; import { registerAnalysisSettingsTools } from "./registerAnalysisSettingsTools.js"; @@ -174,7 +175,13 @@ export const createServer = ( options.providerEnvironment, environment, ); - const delivery = selectToolResultDelivery(environment, options.delivery); + const baseDelivery = selectToolResultDelivery(environment, options.delivery); + const delivery = + session !== undefined && + advertisedToolFamilies !== undefined && + !advertisedToolFamilies.has("session") + ? baseDelivery.withNoTargetRecovery(SCOPED_NO_TARGET_RECOVERY) + : baseDelivery; const capabilities = createCapabilityServices({ environment, providerEnvironment, diff --git a/src/server/recoveryInstructions.ts b/src/server/recoveryInstructions.ts index 60483ea373..ee64745c24 100644 --- a/src/server/recoveryInstructions.ts +++ b/src/server/recoveryInstructions.ts @@ -16,3 +16,7 @@ export const recoveryInstructions = ( runtimeSettingsAvailable: boolean, ): string => `${RECOVERY_START}${runtimeSettingsAvailable ? RUNTIME_SETTINGS_RECOVERY : LAUNCH_SETTINGS_RECOVERY}${EXPLICIT_ARGUMENT_RECOVERY}${RECOVERY_END}`; + +/** Recovery when a scoped session cannot advertise the target lifecycle. */ +export const SCOPED_NO_TARGET_RECOVERY = + "No target is open, and this MCP connection does not advertise open_binary. Preserve needed outputs before restarting; retained in-memory Evidence and Unknown history will not carry over. Add session to REA_MCP_TOOL_FAMILIES in the environment that starts REA and restart the server; then call open_binary with the chosen local path."; diff --git a/tests/boundary/mcp/targetContentFramingMcp.test.ts b/tests/boundary/mcp/targetContentFramingMcp.test.ts index 2b575e5368..044509f6ab 100644 --- a/tests/boundary/mcp/targetContentFramingMcp.test.ts +++ b/tests/boundary/mcp/targetContentFramingMcp.test.ts @@ -1,4 +1,8 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; import { serverInstructions } from "../../../src/server/serverInstructions.js"; +import { SCOPED_NO_TARGET_RECOVERY } from "../../../src/server/recoveryInstructions.js"; +import { NoBinaryOpenError } from "../../../src/domain/configurationErrors.js"; import { UNTRUSTED_CONTENT_INSTRUCTIONS } from "../../../src/server/toolResultEncoding.js"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import type { @@ -8,6 +12,8 @@ import type { import { afterEach, describe, expect, it } from "vitest"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { peExportImage } from "../../fixtures/binaryDiagnostics/nativeExports.js"; import { observed } from "../../fixtures/analysisExecution.js"; import { err, ok } from "../../../src/domain/result.js"; import { toolContract } from "../../../src/contracts/toolContracts.js"; @@ -272,3 +278,83 @@ describe.each([ }, ); }); + +it.each([ + { scope: "direct", sessionAvailable: false }, + { scope: "session,direct", sessionAvailable: true }, + { scope: undefined, sessionAvailable: true }, +])( + "guides a missing target using the advertised scope ($scope)", + async ({ scope, sessionAvailable }) => { + const environment = { ...process.env }; + if (scope === undefined) delete environment.REA_MCP_TOOL_FAMILIES; + else environment.REA_MCP_TOOL_FAMILIES = scope; + const client = await connect(environment); + const names = (await client.listTools()).tools.map(({ name }) => name); + expect(names.includes("open_binary")).toBe(sessionAvailable); + const { error } = parseMcpToolError( + await client.callTool({ + name: "read_bytes", + arguments: { address: "0x1000", length: 4 }, + }), + ); + expect(error.code).toBe("target_unavailable"); + if (sessionAvailable) { + expect(error.remediation.action).toBe(new NoBinaryOpenError().message); + } else { + expect(error.message).toBe(SCOPED_NO_TARGET_RECOVERY); + expect(error.remediation.action).toBe(SCOPED_NO_TARGET_RECOVERY); + expect(error.remediation.action).toContain("REA_MCP_TOOL_FAMILIES"); + expect(error.remediation.action).toContain("open_binary"); + await expect( + client.callTool({ name: "open_binary", arguments: { path: "/tmp/x" } }), + ).rejects.toThrow(/disabled/u); + } + }, +); + +it("warns that retained evidence does not survive the scoped restart", async () => { + const directory = await createTestTempDirectory("rea-scoped-retained-"); + const path = join(directory, "fixture.exe"); + await writeFile(path, peExportImage({})); + const before = await connect({ + ...process.env, + REA_MCP_TOOL_FAMILIES: "direct,native,application", + }); + const observed = await before.callTool({ + name: "inspect_native_exports", + arguments: { path, detail: "summary" }, + }); + expect(observed.isError).not.toBe(true); + const summary = observed.structuredContent as { + normalized_result: { parent_evidence_id: string }; + }; + const view = { + source: { + kind: "retained-evidence", + evidence_id: summary.normalized_result.parent_evidence_id, + }, + view: { kind: "native-exports", collection: "exports" }, + }; + expect( + (await before.callTool({ name: "inspect_analysis_view", arguments: view })) + .isError, + ).not.toBe(true); + const failure = parseMcpToolError( + await before.callTool({ + name: "read_bytes", + arguments: { address: "0x1000", length: 4 }, + }), + ); + expect(failure.error.remediation.action).toBe(SCOPED_NO_TARGET_RECOVERY); + await before.close(); + + const after = await connect({ + ...process.env, + REA_MCP_TOOL_FAMILIES: "session,direct,native,application", + }); + const missing = parseMcpToolError( + await after.callTool({ name: "inspect_analysis_view", arguments: view }), + ); + expect(missing.error.code).toBe("evidence_integrity_mismatch"); +}); From d496d803a4f50dc82248db0630042894c1136d00 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:50:10 +0800 Subject: [PATCH 11/14] fix(kuna): give actionable lookup recovery (#8656) * fix(kuna): give actionable lookup recovery * fix(kuna): keep lookup advice operation neutral * style(kuna): apply repository formatter --- src/kuna/KunaSessionClient.ts | 19 ++++++- .../providers/kuna/kunaAliasSearchMcp.test.ts | 50 ++++++++++++++++++- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/kuna/KunaSessionClient.ts b/src/kuna/KunaSessionClient.ts index f3640d91c9..c6cae4b09f 100644 --- a/src/kuna/KunaSessionClient.ts +++ b/src/kuna/KunaSessionClient.ts @@ -1057,6 +1057,9 @@ export class KunaSessionClient implements AnalysisClient { err( new AnalysisInputError(operation, { userMessage: kunaDataEntryMessage(entry, index.data.get(entry)!), + conformsToInputContract: true, + remediationAction: + "Inspect the data address with address_name or xrefs, or select a procedure entry from list_procedures. Do not repeat the unchanged call.", }), ); const selected = (entry: KunaInventoryFunction) => { @@ -1076,6 +1079,9 @@ export class KunaSessionClient implements AnalysisClient { return err( new AnalysisInputError(operation, { userMessage: `${address} is not a procedure entry Kuna found; list_procedures shows the entries it found.`, + conformsToInputContract: true, + remediationAction: + "Inspect list_procedures and select a discovered entry if it answers your question. An unlisted address is not proof of a function; REA cannot use it as a Kuna procedure in this session. Do not repeat the unchanged call.", }), ); return refused(entry) ? dataRefusal(entry) : selected(entry); @@ -1088,9 +1094,20 @@ export class KunaSessionClient implements AnalysisClient { return dataRefusal(named[0]); return err( new AnalysisInputError(operation, { + conformsToInputContract: true, + remediationAction: + matches.length === 0 && named.length === 0 + ? field === "procedure" + ? "Use search_procedures or list_procedures to inspect Kuna's discovered entries, then select a matching entry. Do not repeat the unchanged call." + : "Inspect the selected target for the intended address and use that address explicitly. Do not repeat the unchanged call." + : matches.length === 0 + ? "Inspect the data addresses with address_name or xrefs, or select a procedure entry from list_procedures. Do not repeat the unchanged call." + : "Select the intended entry by its exact address. Do not repeat the unchanged call.", userMessage: matches.length === 0 && named.length === 0 - ? `Kuna found no procedure named ${selector}; search_procedures finds similar names.` + ? field === "procedure" + ? `Kuna found no procedure named ${selector}; search_procedures finds similar names.` + : `Kuna found no inventory entry named ${selector}.` : matches.length === 0 ? `${selector} names ${String(named.length)} Kuna entries that lie in Mach-O data regions (${named.map(({ address }) => address).join(", ")}); they are data, not procedures.` : `${selector} names ${String(matches.length)} Kuna entries (${matches.map(({ address }) => address).join(", ")}); select one by address.`, diff --git a/tests/boundary/providers/kuna/kunaAliasSearchMcp.test.ts b/tests/boundary/providers/kuna/kunaAliasSearchMcp.test.ts index a633278d30..329ee3c8b2 100644 --- a/tests/boundary/providers/kuna/kunaAliasSearchMcp.test.ts +++ b/tests/boundary/providers/kuna/kunaAliasSearchMcp.test.ts @@ -201,7 +201,7 @@ test("traces a procedure found only by its alias through the composed feature tr test("omits a data entry an alias names while keeping the code entry that shares it", async ({ mcp, }) => { - const { search, observe } = await aliasSession( + const { client, search, observe } = await aliasSession( mcp, "rea-kuna-alias-macho-", { @@ -234,4 +234,52 @@ test("omits a data entry an alias names while keeping the code entry that shares ), ); expect(await search({ pattern: "global_count" })).toEqual([]); + const { error } = parseMcpToolError( + await client.callTool({ + name: "procedure_address", + arguments: { procedure: "global_count" }, + }), + ); + expect(error.recovery).toBeUndefined(); + expect(error.remediation.action).toContain("address_name"); +}); + +test("reports lookup failures with recovery for the selected target, not the input schema", async ({ + mcp, +}) => { + const { client } = await aliasSession(mcp, "rea-kuna-lookup-recovery-", { + functions: VERSIONED, + }); + for (const [procedure, guidance] of [ + ["0x1335", "list_procedures"], + ["absent_name", "search_procedures"], + ["audit_transform", "exact address"], + ]) { + const response = await client.callTool({ + name: "procedure_address", + arguments: { procedure }, + }); + const { error } = parseMcpToolError(response); + expect(error.code).toBe("invalid_request"); + expect(error.recovery).toBeUndefined(); + expect(error.remediation.action).toContain(guidance); + expect(error.remediation.action).not.toContain("input schema"); + } + const assemblyError = parseMcpToolError( + await client.callTool({ + name: "procedure_assembly", + arguments: { procedure: "0x1335" }, + }), + ).error; + expect(assemblyError.remediation.action).toContain("Kuna procedure"); + expect(assemblyError.remediation.action).not.toContain("decompile"); + const missingAddress = parseMcpToolError( + await client.callTool({ + name: "xrefs", + arguments: { address: "absent_name" }, + }), + ).error; + expect(missingAddress.recovery).toBeUndefined(); + expect(missingAddress.message).toContain("no inventory entry named"); + expect(missingAddress.remediation.action).toContain("address"); }); From 193e01abb15917cb4d1ac0f630645f526c02754c Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:10:11 +0800 Subject: [PATCH 12/14] fix(kuna): keep timeout recovery from changing workers (#8687) * fix(kuna): keep timeout recovery from changing workers * test(kuna): verify timeout recovery with real provider --- docs/testing-lanes.md | 5 +- scripts/verify-real-kuna.mjs | 78 ++++++++++++++++++- src/kuna/KunaSessionClient.ts | 2 +- .../providers/kuna/kunaProvider.test.ts | 2 +- 4 files changed, 81 insertions(+), 6 deletions(-) diff --git a/docs/testing-lanes.md b/docs/testing-lanes.md index 4aea0bebc0..f6ade79672 100644 --- a/docs/testing-lanes.md +++ b/docs/testing-lanes.md @@ -421,7 +421,10 @@ the first cold pseudocode answer comes from a single-function run or an export that already finished, assembly waits for and serves from that export, and pseudocode and both dossier facets match their recorded file spans and digests, a fresh server reuses the export without rebuilding it, staging is empty after -a completed export, and no owned process survives. Pass +a completed export, and no owned process survives. A separate forced 1 ms +project deadline checks that a timed-out inventory recommends raising the +project deadline without changing jobs; a fresh server retries with 60 seconds +and the same two-worker setting. Pass `--targets DIR --functions a,b` to add caller-built binaries laid out as `DIR//` beside `.stripped`; addresses come from the symbol-bearing build. `--compare-ghidra` with `GHIDRA_INSTALL_DIR` runs those diff --git a/scripts/verify-real-kuna.mjs b/scripts/verify-real-kuna.mjs index 13659746b8..de799e3ddd 100644 --- a/scripts/verify-real-kuna.mjs +++ b/scripts/verify-real-kuna.mjs @@ -28,7 +28,10 @@ import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { inspectKunaInstallation } from "../dist/kuna/KunaInstallation.js"; import { ArtifactResourceScope } from "../dist/artifacts/ArtifactResourceScope.js"; import { AnalysisCleanupLease } from "../dist/artifacts/AnalysisCleanupLease.js"; -import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs"; +import { + requireMcpOperationResult, + requireMcpToolError, +} from "./lib/mcp-verifier-results.mjs"; import { verifyKunaReferences } from "./lib/kuna-reference-verifier.mjs"; import { nativeKunaStrings, @@ -172,7 +175,7 @@ const sampleTree = async (root) => { }; /** One MCP server with a selected provider, sampled while it runs. */ -const openServer = async (provider) => { +const openServer = async (provider, kunaSettings = {}) => { const owner = Symbol(provider.id); pendingServers.add(owner); const transport = new StdioClientTransport({ @@ -185,6 +188,7 @@ const openServer = async (provider) => { REA_PROCESS_RUN_ID: run.run_id, REA_KUNA_COMMAND: kuna, REA_KUNA_CACHE_DIR: cacheRoot, + ...kunaSettings, ...(process.env.GHIDRA_INSTALL_DIR === undefined ? {} : { GHIDRA_INSTALL_DIR: process.env.GHIDRA_INSTALL_DIR }), @@ -229,6 +233,13 @@ const openServer = async (provider) => { structured: result.structuredContent, }; }; + const callError = async (name, arguments_ = {}) => { + const result = await client.callTool( + { name, arguments: arguments_ }, + { timeout: 3_600_000 }, + ); + return requireMcpToolError(result); + }; const close = async (priorFailure) => { clearInterval(timer); await sample(); @@ -270,7 +281,59 @@ const openServer = async (provider) => { // A rejected connection/target still owns its transport and workspace. await close(cause); } - return { call, close }; + return { call, callError, close }; +}; + +/** Verify a forced inventory timeout can be retried without changing jobs. */ +const verifyProjectTimeoutRecovery = async (path, address) => { + const before = createHash("sha256") + .update(await readFile(path)) + .digest("hex"); + const settings = { + REA_KUNA_JOBS: "2", + REA_KUNA_TIMEOUT_MS: "60000", + REA_KUNA_PROJECT_TIMEOUT_MS: "1", + }; + const timed = await openServer({ path, id: "kuna" }, settings); + let timedFailure; + try { + const failure = await timed.callError("procedure_assembly", { + procedure: address, + }); + assert.equal(failure.code, "capability_unavailable"); + assert.match(failure.message, /kuna functions/u); + assert.match(failure.remediation.action, /REA_KUNA_PROJECT_TIMEOUT_MS/u); + assert.doesNotMatch(failure.remediation.action, /REA_KUNA_JOBS/u); + } catch (cause) { + timedFailure = cause; + } finally { + await timed.close(timedFailure); + } + const retry = await openServer( + { path, id: "kuna" }, + { ...settings, REA_KUNA_PROJECT_TIMEOUT_MS: "60000" }, + ); + let retryFailure; + try { + const assembly = await retry.call("procedure_assembly", { + procedure: address, + }); + assert.equal(typeof assembly.value, "string"); + assert.ok(assembly.value.length > 0); + assert.equal(assembly.structured.analysis_profile.parameters.jobs, 2); + } catch (cause) { + retryFailure = cause; + } finally { + await retry.close(retryFailure); + } + assert.equal( + createHash("sha256") + .update(await readFile(path)) + .digest("hex"), + before, + "Kuna timeout recovery changed the target", + ); + return { forced_deadline_ms: 1, retry_deadline_ms: 60000, jobs: 2 }; }; /** Count structuring and diagnostic markers in provider pseudocode. */ @@ -535,6 +598,7 @@ const verifyVariant = async ({ flavor, directory, symbols }, variant) => { }; const results = []; +let timeoutRecovery; const failures = []; let inspectionResourcesClean = true; try { @@ -596,6 +660,11 @@ try { }), }); + timeoutRecovery = await verifyProjectTimeoutRecovery( + built, + fixtureFunctions.get("rea_checksum"), + ); + // Caller-built matrix. for (const pair of callerPairs) for (const variant of [pair.file, `${pair.file}.stripped`]) @@ -650,6 +719,9 @@ const report = { platform: `${process.platform}/${process.arch}`, verifier_run: lifecycle, results, + ...(timeoutRecovery === undefined + ? {} + : { timeout_recovery: timeoutRecovery }), limitations: [ "Targets are analyzed statically; they are never executed.", "Memory is the sampled peak resident set of the MCP server process tree; CPU is its sampled cumulative time. A short-lived peak between 200 ms samples is not observed.", diff --git a/src/kuna/KunaSessionClient.ts b/src/kuna/KunaSessionClient.ts index c6cae4b09f..04baa3eaa6 100644 --- a/src/kuna/KunaSessionClient.ts +++ b/src/kuna/KunaSessionClient.ts @@ -258,7 +258,7 @@ const exportUnavailable = ( "REA reads Kuna settings when it starts, so restart REA, then reopen the target."; const next = failure instanceof AnalysisTimeoutError - ? `To retry, set REA_KUNA_PROJECT_TIMEOUT_MS above ${String(failure.timeoutMs)} ms or raise REA_KUNA_JOBS in the environment that starts REA. ${restart}` + ? `To retry, set REA_KUNA_PROJECT_TIMEOUT_MS above ${String(failure.timeoutMs)} ms in the environment that starts REA. ${restart}` : failure instanceof AnalysisResourceConstraintError && failure.remediationAction !== undefined ? // A fixed buffer (`memory`) is no setting, so a restart cannot help. diff --git a/tests/boundary/providers/kuna/kunaProvider.test.ts b/tests/boundary/providers/kuna/kunaProvider.test.ts index 6ab5eb1d81..690b4fee9c 100644 --- a/tests/boundary/providers/kuna/kunaProvider.test.ts +++ b/tests/boundary/providers/kuna/kunaProvider.test.ts @@ -899,7 +899,7 @@ it.each([ behavior: { project: "hang" } as const, environment: { REA_KUNA_PROJECT_TIMEOUT_MS: "300" }, cause: "AnalysisTimeoutError", - next: "To retry, set REA_KUNA_PROJECT_TIMEOUT_MS above 300 ms or raise REA_KUNA_JOBS in the environment that starts REA. REA reads Kuna settings when it starts, so restart REA, then reopen the target.", + next: "To retry, set REA_KUNA_PROJECT_TIMEOUT_MS above 300 ms in the environment that starts REA. REA reads Kuna settings when it starts, so restart REA, then reopen the target.", details: { timeout_ms: 300, captured_output: { truncated: expect.any(Boolean) }, From b912ec184ca2ecbd5b59d046187be5b4a59ca3c1 Mon Sep 17 00:00:00 2001 From: Lewis Wang Date: Tue, 3 Nov 2026 10:10:33 +0800 Subject: [PATCH 13/14] fix(javascript): accept retained empty endpoint seeds (#6462) Application analysis preserves the complete literal request endpoint, including the empty string, and the event seed already accepts a retained empty name. The endpoint seed still required a non-empty value, so trace-javascript-semantics refused to select a retained "" endpoint with invalid_request. The endpoint seed now accepts any string and keeps exact matching. Boundary-field seeds keep their minimum because boundary nodes are only produced from method names. Found while fixing #6458; the event half of that report landed in cf5e4f6fa. Co-authored-by: Claude Fable 5.1 --- .../javascriptSemanticQuerySchemas.ts | 4 +- .../javascriptEventPayload.test.ts | 61 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/domain/javascript/javascriptSemanticQuerySchemas.ts b/src/domain/javascript/javascriptSemanticQuerySchemas.ts index 6002e09c41..fa211e757d 100644 --- a/src/domain/javascript/javascriptSemanticQuerySchemas.ts +++ b/src/domain/javascript/javascriptSemanticQuerySchemas.ts @@ -42,9 +42,11 @@ export const javaScriptSemanticQuerySeedSchema = z.discriminatedUnion("kind", [ kind: z.literal("property"), name: z.string(), }), + // Analysis preserves the complete literal endpoint, including the empty + // string, so the seed matches any retained value exactly, as event does. z.strictObject({ kind: z.literal("endpoint"), - value: z.string().min(1), + value: z.string(), }), z.strictObject({ kind: z.literal("event"), diff --git a/tests/acceptance/applications/javascriptEventPayload.test.ts b/tests/acceptance/applications/javascriptEventPayload.test.ts index c4689250de..fbe3a9422a 100644 --- a/tests/acceptance/applications/javascriptEventPayload.test.ts +++ b/tests/acceptance/applications/javascriptEventPayload.test.ts @@ -162,3 +162,64 @@ cliTest( }, 120_000, ); + +cliTest( + "traces a retained empty literal endpoint by its preserved value", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-empty-endpoint-seed-"); + await writeFile( + join(root, "main.js"), + 'export const load = async () => (await fetch("")).json();\n', + ); + const analyzed = await cli.run({ + arguments: [ + "analyze-javascript-application", + root, + "--artifact-format", + "directory", + "--json", + ], + timeoutMs: 60_000, + }); + expect(analyzed.exitCode, analyzed.stderr).toBe(0); + const application = toolContract( + "analyze_javascript_application", + ).outputSchema.parse(analyzed.json); + const graph = javascriptApplicationAnalysisResultSchema.parse( + application.normalized_result, + ).semantic_graph; + // Analysis keeps the complete literal endpoint, here the empty string. + const retained = new Set( + graph.nodes + .filter( + (node) => + (node.kind === "request" || node.kind === "response") && + node.properties.endpoint === "", + ) + .map((node) => node.node_id), + ); + expect(retained.size).toBeGreaterThan(0); + const request = join(root, "endpoint-query.json"); + await writeFile( + request, + JSON.stringify({ + application, + query: { + seed: { kind: "endpoint", value: "" }, + direction: "forward-influence", + }, + }), + ); + const traced = await cli.run({ + arguments: ["trace-javascript-semantics", request, "--json"], + timeoutMs: 60_000, + }); + expect(traced.exitCode, traced.stdout).toBe(0); + const trace = javaScriptSemanticTraceResultSchema.parse( + toolContract("trace_javascript_semantics").outputSchema.parse(traced.json) + .normalized_result, + ); + expect(new Set(trace.seed_node_ids)).toEqual(retained); + }, + 120_000, +); From 9ebbe67e05af08b567e215340da993c08dae378f Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:04:33 +0800 Subject: [PATCH 14/14] fix(ci): await website worker tests and format Wrangler config --- website/worker.test.mjs | 6 +++--- website/wrangler.toml | 7 ++++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/website/worker.test.mjs b/website/worker.test.mjs index 490e8205a9..7c3ae74f9f 100644 --- a/website/worker.test.mjs +++ b/website/worker.test.mjs @@ -36,7 +36,7 @@ function assets({ markdownStatus = 200 } = {}) { }; } -test("HTML is the default and explicit Accept preferences select Markdown", async () => { +await test("HTML is the default and explicit Accept preferences select Markdown", async () => { for (const [accept, markdown] of [ [null, false], ["*/*", false], @@ -75,7 +75,7 @@ test("HTML is the default and explicit Accept preferences select Markdown", asyn } }); -test("HEAD and validators use the selected representation", async () => { +await test("HEAD and validators use the selected representation", async () => { for (const method of ["GET", "HEAD"]) { const response = await worker.fetch( new Request("https://rea.tools/guide/", { @@ -104,7 +104,7 @@ test("HEAD and validators use the selected representation", async () => { } }); -test("missing Markdown keeps HTML, and asset redirects and missing routes survive", async () => { +await test("missing Markdown keeps HTML, and asset redirects and missing routes survive", async () => { const html = await worker.fetch( new Request("https://rea.tools/guide/", { headers: { Accept: "text/markdown" }, diff --git a/website/wrangler.toml b/website/wrangler.toml index 7c64886b30..649d31cfe1 100644 --- a/website/wrangler.toml +++ b/website/wrangler.toml @@ -15,7 +15,12 @@ not_found_handling = "none" [build] cwd = ".." command = "python3 scripts/website.py check" -watch_dir = ["./public", "./locales", "./templates", "../.agents/skills/reverse-engineer-anything"] +watch_dir = [ + "./public", + "./locales", + "./templates", + "../.agents/skills/reverse-engineer-anything", +] # Public HTTPS depends on zone activation and certificate readiness. [env.production]